geneseed 3.14.0 → 3.15.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 +54 -0
- package/SHIPPED.md +1 -1
- package/adapters/opencode/plugins/geneseed-context.js +23 -9
- package/adapters/opencode/plugins/geneseed-guard.js +16 -4
- package/adapters/opencode/plugins/geneseed-learn.js +37 -11
- package/adapters/opencode/plugins/geneseed-notify.js +37 -19
- package/adapters/opencode/plugins/geneseed-workflow.js +3 -1
- package/adapters/opencode/workflows/_runtime.js +16 -3
- package/bin/geneseed-cli.mjs +22 -20
- package/bin/geneseed-hook.mjs +6 -3
- package/harness.config.json +1 -1
- package/js/README.md +9 -5
- package/js/build/args.mjs +4 -3
- package/js/build/bundle.mjs +10 -10
- package/js/build/driver.mjs +8 -7
- package/js/build/emit-claude.mjs +3 -2
- package/js/build/emit-common.mjs +2 -14
- package/js/build/emit-opencode.mjs +12 -8
- package/js/build/generate.mjs +15 -11
- package/js/build/render.mjs +1 -1
- package/js/build/source.mjs +6 -6
- package/js/build/stubs.mjs +6 -5
- package/js/build/version.mjs +21 -2
- package/js/hosts/hooks-context.mjs +2 -1
- package/js/hosts/hooks-prims.mjs +2 -2
- package/js/hosts/hosts.mjs +24 -26
- package/js/hosts/installs.mjs +33 -14
- package/js/hosts/link.mjs +75 -26
- package/js/hosts/memory-files.mjs +13 -4
- package/js/hosts/native.mjs +106 -48
- package/js/hosts/opencode.mjs +44 -36
- package/js/hosts/shim.mjs +4 -2
- package/js/inspect/diff.mjs +5 -6
- package/js/inspect/excludes.mjs +10 -5
- package/js/inspect/status.mjs +18 -21
- package/js/inspect/validate.mjs +2 -2
- package/js/lib/fs.mjs +12 -11
- package/js/lib/json.mjs +4 -4
- package/js/lib/paths.mjs +27 -33
- package/js/lib/proc.mjs +3 -3
- package/js/lib/prompt.mjs +132 -0
- package/js/lib/text.mjs +18 -4
- package/js/lib/udiff.mjs +3 -2
- package/js/maintain/memory.mjs +5 -6
- package/js/maintain/migrate.mjs +4 -3
- package/js/maintain/setup.mjs +9 -131
- package/js/maintain/uninstall.mjs +10 -9
- package/js/maintain/update.mjs +115 -34
- package/js/ui/anim.mjs +8 -12
- package/js/ui/cli.mjs +29 -35
- package/js/ui/menu.mjs +7 -13
- package/js/ui/tui.mjs +14 -123
- package/js/web/actions.mjs +82 -386
- package/js/web/activity.mjs +1 -1
- package/js/web/api.mjs +33 -717
- package/js/web/catalog.mjs +527 -0
- package/js/web/daemon.mjs +21 -3
- package/js/web/handler.mjs +51 -27
- package/js/web/jobs.mjs +24 -2
- package/js/web/routes.mjs +61 -6
- package/js/web/server.mjs +4 -4
- package/js/web/state.mjs +138 -0
- package/js/web/user-files.mjs +339 -0
- package/package.json +1 -1
- package/web/dist/assets/{Activity-CjRqZnr-.js → Activity-kpWxd70F.js} +1 -1
- package/web/dist/assets/{ActivityDetail-OQTxKBj2.js → ActivityDetail-Ccr942VL.js} +1 -1
- package/web/dist/assets/Dashboard-D1lE7jcg.js +2 -0
- package/web/dist/assets/{Docs-CvOZ8akL.js → Docs-BHyaggFy.js} +3 -3
- package/web/dist/assets/{GreenhouseView-6KDjX8da.js → GreenhouseView-Sc_UDMOE.js} +1 -1
- package/web/dist/assets/Installs-Gw-pJ3Zi.js +2 -0
- package/web/dist/assets/JournalView-_gxpsxXi.js +1 -0
- package/web/dist/assets/{Laws-Du0-Zyp2.js → Laws-S9fq1r9T.js} +1 -1
- package/web/dist/assets/{Library-DOkO0TtB.js → Library-MBxadRzn.js} +1 -1
- package/web/dist/assets/{Loops-Dv_aLByJ.js → Loops-Da_JhlzM.js} +2 -2
- package/web/dist/assets/{OperatorHudView-BPP1dtLz.js → OperatorHudView--TkvDybH.js} +1 -1
- package/web/dist/assets/{Personal-26VPYkK0.js → Personal-CTXKHVbV.js} +1 -1
- package/web/dist/assets/Tabs-B0cdf8k6.js +1 -0
- package/web/dist/assets/api-CxgfYu18.js +1 -0
- package/web/dist/assets/drawable-BbWQxe82.js +1 -0
- package/web/dist/assets/index-ujKG1ZNf.js +2 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/Dashboard-J-xCe_Gk.js +0 -2
- package/web/dist/assets/Installs-MWuhNjSm.js +0 -2
- package/web/dist/assets/JournalView-C8MrOz2c.js +0 -1
- package/web/dist/assets/Tabs-BzKuFskD.js +0 -1
- package/web/dist/assets/api-D_ixdyEY.js +0 -1
- package/web/dist/assets/drawable-zVVDbsyw.js +0 -1
- package/web/dist/assets/index-KHt3wE_K.js +0 -2
package/js/hosts/installs.mjs
CHANGED
|
@@ -107,8 +107,8 @@ export const defaultMode = () => configuredDefault('mode', 'direct');
|
|
|
107
107
|
* `_build_render.theme_files` — shipped themes, `_`-prefixed scaffolds excluded.
|
|
108
108
|
*
|
|
109
109
|
* `dir` defaults to the checkout's own `themes/`, which is every caller but one: P2's
|
|
110
|
-
* `syncThemes` is driven over a FIXTURE directory by the
|
|
111
|
-
* `tests/
|
|
110
|
+
* `syncThemes` is driven over a FIXTURE directory by the corpus in
|
|
111
|
+
* `tests/unit/maintainer_tools.test.mjs`, because the tool it belongs to rewrites committed
|
|
112
112
|
* files and cannot be gated against the real ones. A parameter, not a second enumeration —
|
|
113
113
|
* a scaffold mistaken for a theme is exactly what this function exists to prevent, once.
|
|
114
114
|
*
|
|
@@ -162,12 +162,26 @@ function firstCarrier(d, probe, fallback = null) {
|
|
|
162
162
|
return fallback;
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
-
/**
|
|
165
|
+
/**
|
|
166
|
+
* A marker value that can only name a file directly in the themes dir: no separator, no `..`,
|
|
167
|
+
* and not a `_`-prefixed scaffold.
|
|
168
|
+
*/
|
|
169
|
+
const THEME_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* `_harness_setup._theme_of_dir` — the marker, else the sigil in one of five carriers.
|
|
173
|
+
*
|
|
174
|
+
* The marker's value becomes `<themes>/<name>.json` in `loadTheme`, so a path in it
|
|
175
|
+
* (`../harness.config`) loaded any `.json` as a theme. A value that is not a plain name falls
|
|
176
|
+
* through to the sigil scan, as a missing marker does. A plain name that is not shipped is
|
|
177
|
+
* still RETURNED: `loadTheme` then refuses it loudly, which `status`/`rebuild-all`/`migrate`
|
|
178
|
+
* cells pin ("unknown theme 'nosuchtheme'"), rather than a typo going unnoticed.
|
|
179
|
+
*/
|
|
166
180
|
export function themeOfDir(d) {
|
|
167
181
|
const marker = path.join(d, '.geneseed-theme');
|
|
168
182
|
if (isFile(marker)) {
|
|
169
183
|
const name = (readMaybe(marker) ?? '').trim();
|
|
170
|
-
if (name) return name;
|
|
184
|
+
if (THEME_NAME_RE.test(name)) return name;
|
|
171
185
|
}
|
|
172
186
|
return firstCarrier(d, (carrierPath) => themeFromAgent(carrierPath) || undefined);
|
|
173
187
|
}
|
|
@@ -210,7 +224,7 @@ export function footprintOfDir(d) {
|
|
|
210
224
|
* the string, so a posture file named `Foreman` renders `**Foreman**` and one named `FOREMAN`
|
|
211
225
|
* would be scanned for as `**Foreman**` by both implementations only if this reproduces the
|
|
212
226
|
* lowercasing. Every shipped name is already lowercase, which is exactly why the difference
|
|
213
|
-
* would go unnoticed — the corpus
|
|
227
|
+
* would go unnoticed — the former Python corpus covered it, and no test drives it now.
|
|
214
228
|
*/
|
|
215
229
|
export function capitalize(s) {
|
|
216
230
|
return s.length ? s[0].toUpperCase() + s.slice(1).toLowerCase() : s;
|
|
@@ -239,10 +253,15 @@ export const modeOfDir = (d) => leadOfDir(d, discoverNames('modes', 'direct'));
|
|
|
239
253
|
* `Default trust preset: **<Label>**` line, and this scans the places an emit lands it: the
|
|
240
254
|
* native layer (`skills/loop/SKILL.md`, in the root itself for a global install and under the
|
|
241
255
|
* host's `projectMarker` for a project one) and the plain bundle (`skills/loop.md`).
|
|
256
|
+
*
|
|
257
|
+
* `host` narrows the project dirs to that host's own: in a repo carrying `.opencode/` AND
|
|
258
|
+
* `.claude/`, the Claude row otherwise read OpenCode's preset, since OpenCode comes first in
|
|
259
|
+
* `HOSTS`. Without it every host's dir is tried, in `HOSTS` order — the caller that has no host.
|
|
242
260
|
*/
|
|
243
261
|
const TRUST_RE = /^Default trust preset: \*\*(Prudent|Balanced|Aggressive)\*\*/m;
|
|
244
|
-
export function trustOfDir(d) {
|
|
245
|
-
|
|
262
|
+
export function trustOfDir(d, host = null) {
|
|
263
|
+
const rows = host === null ? HOSTS : HOSTS.filter((h) => h.host === host);
|
|
264
|
+
for (const base of ['', ...rows.map((h) => h.projectMarker)]) {
|
|
246
265
|
for (const rel of [['skills', 'loop', 'SKILL.md'], ['skills', 'loop.md']]) {
|
|
247
266
|
const m = TRUST_RE.exec(readMaybe(path.join(d, base, ...rel)) ?? '');
|
|
248
267
|
if (m) return m[1].toLowerCase();
|
|
@@ -406,13 +425,13 @@ export function manifestIsClaude(cfgDir) {
|
|
|
406
425
|
return isDict(claudeReadManifest(cfgDir).managed);
|
|
407
426
|
}
|
|
408
427
|
|
|
409
|
-
/**
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
[
|
|
415
|
-
]);
|
|
428
|
+
/**
|
|
429
|
+
* `_harness_mcp._EMIT_HOST_SCOPE` — a marker's emit name fixes (host, scope). Derived from
|
|
430
|
+
* `HOSTS`, whose emit names are `<host>` and `<host>-global`, so a new host is one row there.
|
|
431
|
+
*/
|
|
432
|
+
export const EMIT_HOST_SCOPE = new Map(HOSTS.flatMap(({ host }) => [
|
|
433
|
+
[host, [host, 'project']], [`${host}-global`, [host, 'global']],
|
|
434
|
+
]));
|
|
416
435
|
|
|
417
436
|
/** `_harness_mcp._emit_host_scope_of` — `root`'s own marker, resolved, or null. */
|
|
418
437
|
export function emitHostScopeOf(root) {
|
package/js/hosts/link.mjs
CHANGED
|
@@ -57,8 +57,8 @@
|
|
|
57
57
|
* never needs to:
|
|
58
58
|
*
|
|
59
59
|
* * `winUserPathScript` is PURE — the escaping, the split and the idempotence live there
|
|
60
|
-
* and `tests/
|
|
61
|
-
* separators, UNC roots)
|
|
60
|
+
* and `tests/unit/win_user_path.test.mjs` runs it over a corpus of hazards (apostrophes,
|
|
61
|
+
* typographic quotes, trailing separators, UNC roots) and asserts each property absolutely.
|
|
62
62
|
* * `cmdLink` SHORT-CIRCUITS when the bin dir is already on `PATH`, so a cell that seeds
|
|
63
63
|
* `PATH` with its own sandboxed bin dir exercises the whole verb and never spawns.
|
|
64
64
|
* * `cmdUnlink` has no such short-circuit, so its cells seed a `PATH` with no `powershell`
|
|
@@ -81,9 +81,11 @@ import os from 'node:os';
|
|
|
81
81
|
import { ROOT } from '../build/source.mjs';
|
|
82
82
|
import { readMaybe } from './installs.mjs';
|
|
83
83
|
import { printOut, printErr, writeText } from '../lib/fs.mjs';
|
|
84
|
-
import { toPlatformPath } from '../lib/paths.mjs';
|
|
84
|
+
import { toPlatformPath, which } from '../lib/paths.mjs';
|
|
85
85
|
|
|
86
86
|
const IS_WIN = process.platform === 'win32';
|
|
87
|
+
/** What `cmdUnlink` recognises a written Unix launcher by — see `isOurLauncher`. */
|
|
88
|
+
const SHIM_MARKER = 'GENESEED_LINK_SHIM';
|
|
87
89
|
|
|
88
90
|
/** `_harness_lifecycle._win_bin_dir`. */
|
|
89
91
|
function winBinDir() {
|
|
@@ -97,12 +99,18 @@ function winBinDir() {
|
|
|
97
99
|
* `_harness_lifecycle._win_user_path_script` — the PowerShell one-liner, built but not run.
|
|
98
100
|
*
|
|
99
101
|
* Every quoting hazard in this verb is in this string, which is why it is a separate
|
|
100
|
-
* function
|
|
102
|
+
* function: `''` is PowerShell's single-quote escape, the `;` split drops
|
|
101
103
|
* empty segments so a trailing separator cannot append an empty entry, and `-notcontains`
|
|
102
104
|
* is what makes `link` idempotent.
|
|
105
|
+
*
|
|
106
|
+
* THE ESCAPE COVERS FIVE CHARACTERS, NOT ONE. PowerShell also reads the typographic quotes
|
|
107
|
+
* U+2018–U+201B as single quotes, so one of them left bare in a directory name closes the
|
|
108
|
+
* string exactly as `'` does and turns the rest of the path into code. Each is doubled with
|
|
109
|
+
* ITSELF — `‘‘` is the escape for `‘` — which is what PowerShell's own `CodeGeneration.
|
|
110
|
+
* EscapeSingleQuotedStringContent` does.
|
|
103
111
|
*/
|
|
104
112
|
export function winUserPathScript(action, directory) {
|
|
105
|
-
const d = directory.
|
|
113
|
+
const d = directory.replace(/['‘-‛]/g, '$&$&'); // PS single-quote escape (O'Brien)
|
|
106
114
|
if (action === 'add') {
|
|
107
115
|
return `$d='${d}';`
|
|
108
116
|
+ "$p=[Environment]::GetEnvironmentVariable('Path','User');"
|
|
@@ -134,13 +142,56 @@ export function winUserPathScript(action, directory) {
|
|
|
134
142
|
* false rather than crashing the verb.
|
|
135
143
|
*/
|
|
136
144
|
function winUserPath(action, directory) {
|
|
137
|
-
|
|
145
|
+
// `which`, never a bare name: Windows resolves a bare name against the cwd first, and
|
|
146
|
+
// `which` never searches the cwd implicitly (see its docblock).
|
|
147
|
+
const ps = which('powershell');
|
|
148
|
+
if (!ps) return false;
|
|
149
|
+
const r = spawnSync(ps,
|
|
138
150
|
['-NoProfile', '-Command', winUserPathScript(action, directory)],
|
|
139
151
|
{ stdio: 'inherit', windowsHide: true });
|
|
140
152
|
if (r.error) return false;
|
|
141
153
|
return r.status === 0;
|
|
142
154
|
}
|
|
143
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Is `dir` one of the ENTRIES of a Windows PATH string?
|
|
158
|
+
*
|
|
159
|
+
* Entry-wise, not a substring test: `…\Geneseed\bin` is a substring of `…\Geneseed\bin2` and of
|
|
160
|
+
* `…\Geneseed\bin\old`, and a substring hit skipped the registry write while the shim's own dir
|
|
161
|
+
* stayed off PATH. Trailing separators and case are both noise on Windows.
|
|
162
|
+
*/
|
|
163
|
+
export function winPathHas(pathEnv, dir) {
|
|
164
|
+
const norm = (s) => s.replace(/[\\/]+$/, '').toLowerCase();
|
|
165
|
+
const want = norm(dir);
|
|
166
|
+
return pathEnv.split(';').some((e) => e && norm(e) === want);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The Unix launcher's text. Single-quoted, never double: inside `"…"` the shell still expands
|
|
171
|
+
* `$`, backticks and `\`, so an install path holding any of them ran as code each time
|
|
172
|
+
* `geneseed` started. `'\''` is the one escape single quotes need.
|
|
173
|
+
*/
|
|
174
|
+
export function unixShimText(exe, entry) {
|
|
175
|
+
const q = (s) => `'${s.replaceAll("'", "'\\''")}'`;
|
|
176
|
+
return `#!/bin/sh\n# ${SHIM_MARKER}\nexec ${q(exe)} ${q(entry)} "$@"\n`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Is the file at `f` a launcher `geneseed link` made? Two shapes exist on disk. (a) A symlink,
|
|
181
|
+
* judged by `os.readlink(f)` raw, then `.name` — NOT a resolved path: the question is whether
|
|
182
|
+
* the link's own target BASENAME is `geneseed`, so a symlink pointing at some other program
|
|
183
|
+
* that happens to sit in a `geneseed` directory is left alone. That is every install made
|
|
184
|
+
* before the shim. (b) A regular file carrying the marker — never "any regular file called
|
|
185
|
+
* geneseed", which would be npm's own bin link or a stranger's program.
|
|
186
|
+
*
|
|
187
|
+
* ONE OWNER FOR BOTH VERBS. `unlink` always refused what is not ours; `link` used to delete
|
|
188
|
+
* whatever sat at its destination before writing, so the two disagreed about the same file.
|
|
189
|
+
*/
|
|
190
|
+
function isOurLauncher(f) {
|
|
191
|
+
if (isSymlink(f)) return path.basename(readlinkSync(f)) === 'geneseed';
|
|
192
|
+
return existsSync(f) && (readMaybe(f) ?? '').includes(SHIM_MARKER);
|
|
193
|
+
}
|
|
194
|
+
|
|
144
195
|
/** `_harness_lifecycle.cmd_link` — put `geneseed` on PATH so it runs from any directory. */
|
|
145
196
|
export function cmdLink(args) {
|
|
146
197
|
const here = ROOT;
|
|
@@ -159,8 +210,7 @@ export function cmdLink(args) {
|
|
|
159
210
|
return 1;
|
|
160
211
|
}
|
|
161
212
|
printOut(`geneseed: wrote shim ${shim}\n`);
|
|
162
|
-
|
|
163
|
-
if (on_path || winUserPath('add', bindir)) {
|
|
213
|
+
if (winPathHas(process.env.PATH || '', bindir) || winUserPath('add', bindir)) {
|
|
164
214
|
printOut(`geneseed: '${bindir}' is on your user PATH — open a NEW terminal, then run \`geneseed\`.\n`);
|
|
165
215
|
} else {
|
|
166
216
|
printOut(`geneseed: add '${bindir}' to your PATH manually, then run \`geneseed\` from anywhere.\n`);
|
|
@@ -174,8 +224,8 @@ export function cmdLink(args) {
|
|
|
174
224
|
// PATH=` line) as well as comparing it against `PATH.split(os.pathsep)`, so an argument
|
|
175
225
|
// that is not already normalised diverges in three places at once. A trailing slash is the
|
|
176
226
|
// cheapest one: `str(Path('/x/bin/'))` is `/x/bin` and the raw string is not.
|
|
177
|
-
//
|
|
178
|
-
// one, and it is
|
|
227
|
+
// The retired cell `link/an-explicit-dir-argument-is-used-instead-of-the-default` passed
|
|
228
|
+
// one, and it is what found this.
|
|
179
229
|
let targetDir = args.dir ? toPlatformPath(args.dir) : null;
|
|
180
230
|
if (targetDir === null) {
|
|
181
231
|
const local = path.join(os.homedir(), '.local', 'bin');
|
|
@@ -191,16 +241,23 @@ export function cmdLink(args) {
|
|
|
191
241
|
try {
|
|
192
242
|
mkdirSync(targetDir, { recursive: true });
|
|
193
243
|
// `dest.is_symlink() or dest.exists()` — the first half is what catches a BROKEN
|
|
194
|
-
// symlink, which `existsSync` follows and reports absent.
|
|
195
|
-
|
|
244
|
+
// symlink, which `existsSync` follows and reports absent. Only OUR launcher is replaced:
|
|
245
|
+
// anything else there — npm's own bin link, a stranger's program — is refused, the same
|
|
246
|
+
// judgement `cmdUnlink` makes about the same file.
|
|
247
|
+
if (isSymlink(dest) || existsSync(dest)) {
|
|
248
|
+
if (!isOurLauncher(dest)) {
|
|
249
|
+
printErr(`geneseed: ${dest} exists and is not a launcher geneseed wrote — leaving it `
|
|
250
|
+
+ 'alone. Remove it yourself, or pick another dir: geneseed link <dir>\n');
|
|
251
|
+
return 1;
|
|
252
|
+
}
|
|
253
|
+
rmSync(dest, { force: true });
|
|
254
|
+
}
|
|
196
255
|
// A WRITTEN SHIM, not a symlink to `ROOT/geneseed`. The bash launcher execs a Python
|
|
197
256
|
// interpreter, so symlinking it puts Python on PATH from the Node verb. The Windows
|
|
198
|
-
// arm above has always written a shim; this is the same shape. `
|
|
199
|
-
// is
|
|
257
|
+
// arm above has always written a shim; this is the same shape. `SHIM_MARKER`
|
|
258
|
+
// is what `cmdUnlink` recognises — without it, unlink cannot tell our file from
|
|
200
259
|
// a stranger's and would have to refuse every regular file, which is a no-op.
|
|
201
|
-
writeText(dest,
|
|
202
|
-
+ '# GENESEED_LINK_SHIM\n'
|
|
203
|
-
+ `exec "${process.execPath}" "${entry}" "$@"\n`);
|
|
260
|
+
writeText(dest, unixShimText(process.execPath, entry));
|
|
204
261
|
chmodSync(dest, 0o755);
|
|
205
262
|
} catch (e) {
|
|
206
263
|
printErr(`geneseed: could not write ${dest} (${asOsError(e)}) — pick a writable dir: `
|
|
@@ -246,15 +303,7 @@ export function cmdUnlink() {
|
|
|
246
303
|
if (seen.has(d)) continue;
|
|
247
304
|
seen.add(d);
|
|
248
305
|
const f = path.join(d, 'geneseed');
|
|
249
|
-
|
|
250
|
-
// — NOT a resolved path: the reference asks whether the link's own target BASENAME is
|
|
251
|
-
// `geneseed`, so a symlink pointing at some other program that happens to sit in a
|
|
252
|
-
// `geneseed` directory is left alone. That is every install made before the shim.
|
|
253
|
-
// (b) a regular file we wrote, identified by its marker — never "any regular file
|
|
254
|
-
// called geneseed", which would delete a stranger's program.
|
|
255
|
-
const ours = (isSymlink(f) && path.basename(readlinkSync(f)) === 'geneseed')
|
|
256
|
-
|| (!isSymlink(f) && existsSync(f) && (readMaybe(f) ?? '').includes('GENESEED_LINK_SHIM'));
|
|
257
|
-
if (ours) {
|
|
306
|
+
if (isOurLauncher(f)) {
|
|
258
307
|
try {
|
|
259
308
|
rmSync(f);
|
|
260
309
|
printOut(`geneseed: removed ${f}\n`);
|
|
@@ -74,18 +74,25 @@ function globMd(dir) {
|
|
|
74
74
|
&& isFile(path.join(dir, n)));
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/** Stems that name the store's own files, never a fact — `MEMORY` is the index itself. */
|
|
78
|
+
const RESERVED_STEMS = new Set(['memory', 'readme']);
|
|
79
|
+
|
|
77
80
|
/** `_existing_slugs` — slugs already stored, so learn never re-emits a known fact. */
|
|
78
81
|
export function existingSlugs(memDir) {
|
|
79
|
-
|
|
80
|
-
return new Set(globMd(memDir).map(stem).filter((s) => !skip.has(s.toLowerCase())));
|
|
82
|
+
return new Set(globMd(memDir).map(stem).filter((s) => !RESERVED_STEMS.has(s.toLowerCase())));
|
|
81
83
|
}
|
|
82
84
|
|
|
83
85
|
/**
|
|
84
86
|
* `_write_memories` — split the model output into files, write each NEW one, and append a
|
|
85
87
|
* pointer line to MEMORY.md. `existing` is MUTATED, so a reply carrying the same slug
|
|
86
88
|
* twice writes it once.
|
|
89
|
+
*
|
|
90
|
+
* A reserved stem is refused (`name: MEMORY` would overwrite the index), and names compare
|
|
91
|
+
* LOWERCASE: on Windows and macOS `User-Prefs.md` and `user-prefs.md` are one file, so a
|
|
92
|
+
* case variant of a stored slug would overwrite it.
|
|
87
93
|
*/
|
|
88
94
|
export function writeMemories(modelOutput, memDir, existing) {
|
|
95
|
+
const taken = new Set([...existing].map((s) => s.toLowerCase()));
|
|
89
96
|
const written = [];
|
|
90
97
|
const indexLines = [];
|
|
91
98
|
for (let chunk of modelOutput.split(FILE_SEP_RE)) {
|
|
@@ -93,9 +100,11 @@ export function writeMemories(modelOutput, memDir, existing) {
|
|
|
93
100
|
if (!chunk || chunk.toUpperCase() === 'NOTHING') continue;
|
|
94
101
|
const [fm] = frontmatter(chunk);
|
|
95
102
|
const name = fmGet(fm, 'name').trim();
|
|
96
|
-
|
|
103
|
+
const key = name.toLowerCase();
|
|
104
|
+
if (!MEMORY_SLUG_RE.test(name) || RESERVED_STEMS.has(key) || taken.has(key)) continue;
|
|
97
105
|
writeText(path.join(memDir, `${name}.md`), `${chunk.replace(/\n+$/, '')}\n`);
|
|
98
106
|
existing.add(name);
|
|
107
|
+
taken.add(key);
|
|
99
108
|
written.push(name);
|
|
100
109
|
const desc = fmGet(fm, 'description').trim();
|
|
101
110
|
indexLines.push(`- [${name}](${name}.md)${desc ? ` \u2014 ${desc}` : ''}`);
|
|
@@ -147,7 +156,7 @@ export function memoryDropIndex(memDir, name) {
|
|
|
147
156
|
* the user's to keep).
|
|
148
157
|
*/
|
|
149
158
|
export function consolidateMemory(memDir) {
|
|
150
|
-
const skip =
|
|
159
|
+
const skip = RESERVED_STEMS;
|
|
151
160
|
const facts = new Map();
|
|
152
161
|
for (const name of sortPaths(globMd(memDir))) {
|
|
153
162
|
const s = stem(name);
|
package/js/hosts/native.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A faithful translation of `_build_emit._write_native_layer` and the frontmatter
|
|
5
5
|
* builders it dispatches to. Capability specs become host-native subagents and skills:
|
|
6
|
-
* one dialect per host (OpenCode / Claude Code), vendored skill folders copied
|
|
6
|
+
* one agent dialect per host family (OpenCode / Claude Code), vendored skill folders copied
|
|
7
7
|
* through verbatim, authoring templates shipped flat.
|
|
8
8
|
*
|
|
9
9
|
* WHY THIS IS THE HARD HALF. `js/build/render.mjs` is a pure function of `src/` and `themes/`;
|
|
@@ -15,8 +15,7 @@
|
|
|
15
15
|
* 2. Claim-on-create decides which files get written AT ALL: a target that already
|
|
16
16
|
* exists and was not in the previous manifest belongs to the user, and is skipped
|
|
17
17
|
* with a warning. So the output is a function of what is already on disk.
|
|
18
|
-
* 3.
|
|
19
|
-
* (`<name>.agent.md`) that drags a link rewrite along with it.
|
|
18
|
+
* 3. Two agent dialects diverge inside one loop, on the same filenames.
|
|
20
19
|
*
|
|
21
20
|
* None of the three is reachable from the golden harness's happy path: golden emits into
|
|
22
21
|
* a fresh tree with an empty overrides stub. `tests/unit/user_files.test.mjs` drives
|
|
@@ -32,9 +31,10 @@ import { existsSync, mkdirSync } from 'node:fs';
|
|
|
32
31
|
import path from 'node:path';
|
|
33
32
|
import { writeText, copyFile, readText } from '../lib/fs.mjs';
|
|
34
33
|
import { jsonDumps, parseJson, formatValue, formatRepr, isTruthy, isDict } from '../lib/json.mjs';
|
|
34
|
+
import { relPosix } from '../lib/text.mjs';
|
|
35
35
|
|
|
36
36
|
/** What a folder skill writes where it needs its own directory — see `writeNativeLayer`. */
|
|
37
|
-
|
|
37
|
+
const SKILL_DIR_PLACEHOLDER = '<this-skill-directory>';
|
|
38
38
|
|
|
39
39
|
/** Mirrors `_build_core.VENDORED_SKILL_DIRS`. */
|
|
40
40
|
export const VENDORED_SKILL_DIRS = new Set(['react-view-transitions', 'daydream', 'token-report', 'explain-changes']);
|
|
@@ -108,11 +108,6 @@ export function firstBlockquote(text) {
|
|
|
108
108
|
return '';
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
/** `_build_emit.desc_of`. */
|
|
112
|
-
export function descOf(text) {
|
|
113
|
-
return firstBlockquote(text);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
111
|
/**
|
|
117
112
|
* The `**Trigger:**` paragraph of a skill, flattened to one line — markdown links reduced
|
|
118
113
|
* to their text, HTML comments dropped, whitespace collapsed. '' when the skill has none.
|
|
@@ -155,7 +150,7 @@ function triggerOf(text) {
|
|
|
155
150
|
*/
|
|
156
151
|
const DESCRIPTION_CAP = 320;
|
|
157
152
|
function skillDescription(text) {
|
|
158
|
-
return withWhen(
|
|
153
|
+
return withWhen(firstBlockquote(text), triggerOf(text));
|
|
159
154
|
}
|
|
160
155
|
|
|
161
156
|
/**
|
|
@@ -188,7 +183,7 @@ function dispatchOf(text) {
|
|
|
188
183
|
|
|
189
184
|
/** The emitted `description:` of an agent — purpose + its first dispatch condition. */
|
|
190
185
|
export function agentDescription(text) {
|
|
191
|
-
return withWhen(
|
|
186
|
+
return withWhen(firstBlockquote(text), dispatchOf(text));
|
|
192
187
|
}
|
|
193
188
|
|
|
194
189
|
/** Purpose + ` Use when: ` + the first sentence of `trig`, capped — see `skillDescription`. */
|
|
@@ -299,11 +294,9 @@ function given(v) {
|
|
|
299
294
|
* `Object.prototype` for an agent named `constructor`, `toString` or `valueOf`, and would
|
|
300
295
|
* hand back a function where Python hands back `None`.
|
|
301
296
|
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
* while this returns undefined for every key and emits no override lines. Not a byte
|
|
306
|
-
* difference on any path the gate covers — a difference in which malformed input crashes.
|
|
297
|
+
* A value that is present but NOT an object (`"reviewer": "haiku"` instead of
|
|
298
|
+
* `"reviewer": {"model": "haiku"}`) never reaches here from a file: `loadAgentOverrides`
|
|
299
|
+
* warns and drops it. A caller handing one in directly gets no override lines.
|
|
307
300
|
*/
|
|
308
301
|
function agentOverride(overrides, stem) {
|
|
309
302
|
return (Object.hasOwn(overrides, stem) && overrides[stem]) || {};
|
|
@@ -366,13 +359,36 @@ function opencodeAgentFrontmatter(stem, text, overrides, theme = null) {
|
|
|
366
359
|
return fm;
|
|
367
360
|
}
|
|
368
361
|
|
|
369
|
-
/** `
|
|
362
|
+
/** The keys the OpenCode dialect renders. Claude's honours `model` alone — see `writeNativeLayer`. */
|
|
363
|
+
const OVERRIDE_KEYS = ['model', 'temperature', 'variant', 'steps'];
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* '' when `v` may be written into a frontmatter line, else why not.
|
|
367
|
+
*
|
|
368
|
+
* Each value lands VERBATIM after `key: `, so a string carrying a line break writes frontmatter
|
|
369
|
+
* keys of its own (`"haiku\npermission: allow"`), and anything `formatValue` cannot render — a
|
|
370
|
+
* boolean, a list, an object — threw a bare TypeError that aborted the emit with a stack trace
|
|
371
|
+
* naming nothing the user wrote.
|
|
372
|
+
*/
|
|
373
|
+
function overrideProblem(v) {
|
|
374
|
+
if (typeof v === 'string') return /[\r\n\u2028\u2029]/.test(v) ? 'contains a line break' : '';
|
|
375
|
+
// `parseJson` yields exactly one object `isDict` refuses: its number wrapper.
|
|
376
|
+
const isNumber = typeof v === 'object' && v !== null && !Array.isArray(v) && !isDict(v);
|
|
377
|
+
if (isNumber && Number.isFinite(+v)) return '';
|
|
378
|
+
return 'is not a string or a finite number';
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* `_build_emit._load_agent_overrides`, with every value that would break the frontmatter
|
|
383
|
+
* dropped and named on stderr — the user's file, so a bad entry is a warning, never an abort.
|
|
384
|
+
*/
|
|
370
385
|
export function loadAgentOverrides(base) {
|
|
386
|
+
const file = path.join(base, 'agent-overrides.json');
|
|
371
387
|
let data;
|
|
372
388
|
try {
|
|
373
389
|
// parseJson, not JSON.parse: the override values are rendered with `formatValue`, which
|
|
374
390
|
// needs the int/float distinction the raw literal carries and `JSON.parse` discards.
|
|
375
|
-
data = parseJson(readText(
|
|
391
|
+
data = parseJson(readText(file));
|
|
376
392
|
} catch {
|
|
377
393
|
// Missing or malformed => no overrides, so agents inherit the host model.
|
|
378
394
|
// Wider than Python's `except (OSError, json.JSONDecodeError)` by one case: a file
|
|
@@ -382,12 +398,72 @@ export function loadAgentOverrides(base) {
|
|
|
382
398
|
return {};
|
|
383
399
|
}
|
|
384
400
|
const agents = isDict(data) ? data.agents : null;
|
|
385
|
-
|
|
401
|
+
if (!isDict(agents)) return {};
|
|
402
|
+
// In place: `data` is a fresh parse, and deleting keeps a `__proto__`-named entry an own
|
|
403
|
+
// property, where copying into a new object would assign a prototype instead.
|
|
404
|
+
for (const [stem, entry] of Object.entries(agents)) {
|
|
405
|
+
if (entry === null) continue;
|
|
406
|
+
if (!isDict(entry)) {
|
|
407
|
+
warn(`[geneseed] WARN: ${file}: agents.${stem} is not an object — skipped`);
|
|
408
|
+
delete agents[stem];
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
for (const key of OVERRIDE_KEYS) {
|
|
412
|
+
if (!given(entry[key])) continue;
|
|
413
|
+
const why = overrideProblem(entry[key]);
|
|
414
|
+
if (!why) continue;
|
|
415
|
+
warn(`[geneseed] WARN: ${file}: agents.${stem}.${key} ${why} — skipped`);
|
|
416
|
+
delete entry[key];
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return agents;
|
|
386
420
|
}
|
|
387
421
|
|
|
388
|
-
/**
|
|
389
|
-
|
|
390
|
-
|
|
422
|
+
/**
|
|
423
|
+
* Warn once per override key the Claude dialect drops. The seeded `agent-overrides.json`
|
|
424
|
+
* advertises all four keys on every host, and Claude Code's agent frontmatter takes `model`
|
|
425
|
+
* only — so a `temperature` set there was silently a no-op.
|
|
426
|
+
*/
|
|
427
|
+
function warnClaudeIgnored(overrides) {
|
|
428
|
+
const ignored = new Set();
|
|
429
|
+
for (const entry of Object.values(overrides)) {
|
|
430
|
+
if (!isDict(entry)) continue;
|
|
431
|
+
for (const key of OVERRIDE_KEYS.slice(1)) if (given(entry[key])) ignored.add(key);
|
|
432
|
+
}
|
|
433
|
+
for (const key of ignored) {
|
|
434
|
+
warn(`[geneseed] WARN: agent-overrides.json sets '${key}', which Claude-family hosts do `
|
|
435
|
+
+ "not support — only 'model' is applied");
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Claim-on-create, as a closure over one emit's prior manifest: `claim(dest)` is true when
|
|
441
|
+
* `dest` may be (over)written, false for a pre-existing file no previous emit owned — the
|
|
442
|
+
* user's, left alone, named on stderr, and kept out of the manifest by the caller.
|
|
443
|
+
*
|
|
444
|
+
* Its own function because every writer whose path lands in `owned` needs it, and uninstall
|
|
445
|
+
* deletes what `owned` names: the OpenCode command and primary-agent writers pushed their
|
|
446
|
+
* paths without it, so uninstall removed a user's own `command/commit.md`. ONE closure per
|
|
447
|
+
* emit, shared by all of them, so the pre-manifest header still prints once.
|
|
448
|
+
*
|
|
449
|
+
* `oldOwned === null` (or no `cfg`) is a caller doing no ownership at all: claim everything.
|
|
450
|
+
*/
|
|
451
|
+
export function claimer(oldOwned, cfg, manifestExisted = true) {
|
|
452
|
+
const oldSet = oldOwned !== null && oldOwned !== undefined ? new Set(oldOwned) : null;
|
|
453
|
+
let headerPrinted = false;
|
|
454
|
+
return (dest) => {
|
|
455
|
+
if (oldSet === null || cfg === null || !existsSync(dest)) return true;
|
|
456
|
+
const rel = relPosix(cfg, dest);
|
|
457
|
+
if (oldSet.has(rel)) return true;
|
|
458
|
+
if (!manifestExisted && !headerPrinted) {
|
|
459
|
+
warn('[geneseed] first emit over a pre-manifest install — existing files '
|
|
460
|
+
+ 'are treated as yours');
|
|
461
|
+
headerPrinted = true;
|
|
462
|
+
}
|
|
463
|
+
warn(`[geneseed] kept your existing ${rel} — skipped Geneseed's copy to avoid `
|
|
464
|
+
+ 'clobbering it');
|
|
465
|
+
return false;
|
|
466
|
+
};
|
|
391
467
|
}
|
|
392
468
|
|
|
393
469
|
/**
|
|
@@ -406,6 +482,9 @@ function relPosix(base, target) {
|
|
|
406
482
|
* has the placeholder replaced by the answer. Only vendored folders carry the placeholder — a
|
|
407
483
|
* flat skill has no siblings to point at.
|
|
408
484
|
*
|
|
485
|
+
* `opts.claim` — a `claimer` closure the caller shares with its other writers; built from
|
|
486
|
+
* `oldOwned`/`cfg`/`manifestExisted` when absent.
|
|
487
|
+
*
|
|
409
488
|
* Returns `{ nAgents, nSkills, written }` — Python's 3-tuple, with `written` as absolute
|
|
410
489
|
* paths in write order.
|
|
411
490
|
*
|
|
@@ -419,30 +498,13 @@ function relPosix(base, target) {
|
|
|
419
498
|
*/
|
|
420
499
|
export function writeNativeLayer(items, agentsDir, skillsDir, overrides = null, {
|
|
421
500
|
host = 'opencode', oldOwned = null, cfg = null, manifestExisted = true,
|
|
422
|
-
theme = null, src, skillDirOf = null,
|
|
501
|
+
theme = null, src, skillDirOf = null, claim = claimer(oldOwned, cfg, manifestExisted),
|
|
423
502
|
} = {}) {
|
|
424
503
|
const ov = overrides || {};
|
|
425
|
-
|
|
504
|
+
if (host === 'claude') warnClaudeIgnored(ov);
|
|
426
505
|
let nAgents = 0;
|
|
427
506
|
let nSkills = 0;
|
|
428
507
|
const written = [];
|
|
429
|
-
let headerPrinted = false;
|
|
430
|
-
|
|
431
|
-
// True -> ok to (over)write; false -> a pre-existing file we never owned, so it is the
|
|
432
|
-
// user's: leave it, warn, and keep it out of the manifest.
|
|
433
|
-
const claim = (dest) => {
|
|
434
|
-
if (oldSet === null || cfg === null || !existsSync(dest)) return true;
|
|
435
|
-
const rel = relPosix(cfg, dest);
|
|
436
|
-
if (oldSet.has(rel)) return true;
|
|
437
|
-
if (!manifestExisted && !headerPrinted) {
|
|
438
|
-
warn('[geneseed] first emit over a pre-manifest install — existing files '
|
|
439
|
-
+ 'are treated as yours');
|
|
440
|
-
headerPrinted = true;
|
|
441
|
-
}
|
|
442
|
-
warn(`[geneseed] kept your existing ${rel} — skipped Geneseed's copy to avoid `
|
|
443
|
-
+ 'clobbering it');
|
|
444
|
-
return false;
|
|
445
|
-
};
|
|
446
508
|
|
|
447
509
|
const write = (dest, text) => {
|
|
448
510
|
mkdirSync(path.dirname(dest), { recursive: true });
|
|
@@ -498,13 +560,9 @@ export function writeNativeLayer(items, agentsDir, skillsDir, overrides = null,
|
|
|
498
560
|
let dest;
|
|
499
561
|
let kind;
|
|
500
562
|
if (folder === 'agents') {
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
} else {
|
|
505
|
-
fm = opencodeAgentFrontmatter(stem, text, ov, theme);
|
|
506
|
-
dest = path.join(agentsDir, `${stem}.md`);
|
|
507
|
-
}
|
|
563
|
+
fm = host === 'claude' ? claudeAgentFrontmatter(stem, text, ov)
|
|
564
|
+
: opencodeAgentFrontmatter(stem, text, ov, theme);
|
|
565
|
+
dest = path.join(agentsDir, `${stem}.md`);
|
|
508
566
|
kind = 'agent';
|
|
509
567
|
} else {
|
|
510
568
|
// Skills are BYTE-IDENTICAL across hosts: name + description, body link-stripped.
|