geneseed 4.10.0 → 4.15.1
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 +106 -0
- package/README.md +5 -4
- package/SHIPPED.md +1 -1
- package/adapters/opencode/plugins/geneseed-guard.js +16 -1
- package/adapters/opencode/plugins/geneseed-learn.js +28 -12
- package/adapters/opencode/plugins/geneseed-workflow.js +19 -2
- package/docs/concepts/kits.md +14 -2
- package/docs/concepts/skills.md +1 -1
- package/docs/extending.md +3 -4
- package/docs/guides/install-bob.md +3 -1
- package/docs/guides/run-anywhere.md +2 -2
- package/docs/guides/upgrade.md +2 -2
- package/docs/reference/troubleshoot-install.md +1 -1
- package/docs/web-ui.md +6 -1
- package/harness.config.json +1 -1
- package/js/build/emit-claude.mjs +4 -2
- package/js/build/stubs.mjs +13 -0
- package/js/hosts/link.mjs +88 -1
- package/js/inspect/checks-build.mjs +8 -2
- package/js/inspect/cost.mjs +1 -1
- package/js/inspect/diff.mjs +53 -7
- package/js/inspect/inventory.mjs +1 -0
- package/js/lib/prompt.mjs +19 -2
- package/js/maintain/setup.mjs +92 -28
- package/js/maintain/update.mjs +2 -2
- package/js/web/actions.mjs +8 -0
- package/js/web/api.mjs +39 -17
- package/js/web/catalog.mjs +17 -2
- package/js/web/handler.mjs +1 -0
- package/js/web/server.mjs +11 -5
- package/js/web/state.mjs +38 -3
- package/package.json +1 -1
- package/registry.json +28 -0
- package/src/AGENT.md.tmpl +5 -5
- package/src/agents/_self-improvement.md +11 -0
- package/src/agents/_template.md +1 -8
- package/src/agents/advocate.md +1 -8
- package/src/agents/architect.md +1 -8
- package/src/agents/developer.md +1 -8
- package/src/agents/devops-engineer.md +1 -9
- package/src/agents/docker-expert.md +1 -9
- package/src/agents/docs.md +1 -8
- package/src/agents/empiricist.md +1 -8
- package/src/agents/explorer.md +1 -8
- package/src/agents/framer.md +1 -8
- package/src/agents/historian.md +1 -8
- package/src/agents/kubernetes-specialist.md +1 -9
- package/src/agents/memory-curator.md +1 -8
- package/src/agents/operator.md +1 -8
- package/src/agents/performance.md +1 -8
- package/src/agents/pragmatist.md +1 -8
- package/src/agents/react-engineer.md +38 -0
- package/src/agents/react-reviewer.md +42 -0
- package/src/agents/researcher.md +5 -10
- package/src/agents/reviewer.md +1 -8
- package/src/agents/security.md +1 -8
- package/src/agents/skeptic.md +1 -8
- package/src/agents/spring-boot-engineer.md +1 -9
- package/src/agents/spring-reviewer.md +1 -9
- package/src/agents/spring-security.md +1 -9
- package/src/agents/steward.md +1 -8
- package/src/agents/tester.md +1 -8
- package/src/agents/user-advocate.md +1 -8
- package/src/agents/visionary.md +1 -8
- package/src/doctrines/comms.md +21 -0
- package/src/kits/react/rules.md +13 -0
- package/src/kits/react.json +18 -0
- package/src/licenses/everything-claude-code.txt +21 -0
- package/src/skills/_self-improvement.md +22 -5
- package/src/skills/brainstorm.md +7 -2
- package/src/skills/codebase-design.md +6 -2
- package/src/skills/debug.md +1 -1
- package/src/skills/develop.md +4 -2
- package/src/skills/handoff.md +1 -1
- package/src/skills/parallel-agents.md +3 -1
- package/src/skills/plan.md +5 -2
- package/src/skills/prototype.md +4 -2
- package/src/skills/react/a11y.md +33 -0
- package/src/skills/react/vite.md +35 -0
- package/src/skills/react/webpack.md +40 -0
- package/src/skills/react-testing.md +68 -0
- package/src/skills/react.md +63 -0
- package/src/skills/research.md +4 -2
- package/src/skills/rule.md +4 -2
- package/src/skills/ship.md +6 -4
- package/src/skills/skill-forge.md +10 -5
- package/src/skills/wayfinder.md +6 -2
- package/src/skills/wiki.md +18 -5
- package/src/upstreams.json +7 -0
- package/themes/_TEMPLATE.json +5 -0
- package/themes/biker.json +5 -0
- package/themes/commentator.json +5 -0
- package/themes/cyberpunk.json +5 -0
- package/themes/gamer.json +5 -0
- package/themes/imperial.json +5 -0
- package/themes/joker.json +5 -0
- package/themes/marvin.json +5 -0
- package/themes/mean.json +5 -0
- package/themes/military.json +5 -0
- package/themes/neutral.json +5 -0
- package/themes/pirate.json +5 -0
- package/themes/sports.json +5 -0
- package/themes/verstappen.json +5 -0
- package/themes/wizard.json +5 -0
- package/web/dist/assets/Activity-M5qj6CxJ.js +1 -0
- package/web/dist/assets/ActivityDetail-DVCdNy73.js +1 -0
- package/web/dist/assets/Dashboard-B1rpgTz-.js +2 -0
- package/web/dist/assets/{Docs-DLVckkew.js → Docs-D-EixVuX.js} +3 -3
- package/web/dist/assets/FilterInput-CZREvrjX.js +1 -0
- package/web/dist/assets/GreenhouseView-BvA4t12P.js +2 -0
- package/web/dist/assets/Installs-DP4WjTpD.js +3 -0
- package/web/dist/assets/JournalView-hf2eO133.js +1 -0
- package/web/dist/assets/KitApply-aORiqjFC.js +1 -0
- package/web/dist/assets/Kits-DpIEozdv.js +1 -0
- package/web/dist/assets/{Laws-BkKnZOBI.js → Laws-cM_4CG9i.js} +2 -2
- package/web/dist/assets/Library-I1zEH3UW.js +1 -0
- package/web/dist/assets/Loops-CDa_mxP9.js +2 -0
- package/web/dist/assets/{Markdown-iHvBrpRN.js → Markdown-zaXXXHz9.js} +13 -13
- package/web/dist/assets/OperatorHudView-CMf1DQMB.js +2 -0
- package/web/dist/assets/Personal-BqU9Pzt1.js +1 -0
- package/web/dist/assets/Tabs-JZ2FQBcz.js +1 -0
- package/web/dist/assets/activity-Jw8XcPiP.js +1 -0
- package/web/dist/assets/{drawable-CgYi0Dtg.js → drawable-svc6-2Gp.js} +1 -1
- package/web/dist/assets/index-CZFrNe4N.js +2 -0
- package/web/dist/assets/{index-BCJz4_8C.css → index-jYlWihN-.css} +1 -1
- package/web/dist/assets/{react-BOF6yL40.js → react-DlYQp10C.js} +1 -1
- package/web/dist/assets/rolldown-runtime-hePW80VL.js +1 -0
- package/web/dist/assets/sections-ChwaobfW.js +1 -0
- package/web/dist/assets/usePoll-bDFN-Hr6.js +1 -0
- package/web/dist/index.html +5 -4
- package/web/src/pages/Laws.jsx +5 -0
- package/web/dist/assets/Activity-BjyJaf8e.js +0 -1
- package/web/dist/assets/ActivityDetail-Bo9nRDNV.js +0 -1
- package/web/dist/assets/Dashboard-BQJGU7vE.js +0 -2
- package/web/dist/assets/FilterInput-zLFXoSt6.js +0 -1
- package/web/dist/assets/GreenhouseView--DML7CW3.js +0 -2
- package/web/dist/assets/Installs-CxCiln1F.js +0 -3
- package/web/dist/assets/JournalView-ymgZleHi.js +0 -1
- package/web/dist/assets/Library-C8ercIZv.js +0 -1
- package/web/dist/assets/Loops-C95zhbFl.js +0 -2
- package/web/dist/assets/OperatorHudView-B9qwc3w3.js +0 -2
- package/web/dist/assets/Personal-DyR72AF-.js +0 -1
- package/web/dist/assets/Tabs-ChCr_HqV.js +0 -1
- package/web/dist/assets/activity-DvPxoS4Y.js +0 -1
- package/web/dist/assets/index-B32EvfDT.js +0 -2
- package/web/dist/assets/sections-BET3c1WT.js +0 -1
- package/web/dist/assets/usePoll-NSTzLOF4.js +0 -1
package/js/hosts/link.mjs
CHANGED
|
@@ -74,6 +74,7 @@
|
|
|
74
74
|
import {
|
|
75
75
|
chmodSync, existsSync, lstatSync, mkdirSync, readlinkSync, rmSync,
|
|
76
76
|
} from 'node:fs';
|
|
77
|
+
import { isatty } from 'node:tty';
|
|
77
78
|
import { spawnSync } from 'node:child_process';
|
|
78
79
|
import path from 'node:path';
|
|
79
80
|
import os from 'node:os';
|
|
@@ -81,12 +82,84 @@ import os from 'node:os';
|
|
|
81
82
|
import { ROOT } from '../build/source.mjs';
|
|
82
83
|
import { readMaybe } from './installs.mjs';
|
|
83
84
|
import { printOut, printErr, writeText } from '../lib/fs.mjs';
|
|
85
|
+
import { confirm } from '../lib/prompt.mjs';
|
|
84
86
|
import { toPlatformPath, which } from '../lib/paths.mjs';
|
|
85
87
|
|
|
86
88
|
const IS_WIN = process.platform === 'win32';
|
|
87
89
|
/** What `cmdUnlink` recognises a written Unix launcher by — see `isOurLauncher`. */
|
|
88
90
|
const SHIM_MARKER = 'GENESEED_LINK_SHIM';
|
|
89
91
|
|
|
92
|
+
/** The lines that open and close the PATH block `link` writes into a shell startup file. */
|
|
93
|
+
const RC_BEGIN = '# >>> geneseed >>> (added by `geneseed link`; `geneseed unlink` removes it)';
|
|
94
|
+
const RC_END = '# <<< geneseed <<<';
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The startup file to put the bin dir on PATH in, for this login shell — or null when it is
|
|
98
|
+
* not one we know how to edit (fish, csh, none), where `link` prints the line instead.
|
|
99
|
+
*
|
|
100
|
+
* zsh reads `$ZDOTDIR/.zshrc` (default `~`). bash reads `~/.bashrc` on Linux, but macOS
|
|
101
|
+
* Terminal opens LOGIN shells, which read `~/.bash_profile` and not `~/.bashrc`. Pure, for the
|
|
102
|
+
* test: nothing here may touch the real user's files.
|
|
103
|
+
*/
|
|
104
|
+
export function shellRcFile({
|
|
105
|
+
shell = process.env.SHELL || '', home = os.homedir(), zdotdir = process.env.ZDOTDIR,
|
|
106
|
+
platform = process.platform,
|
|
107
|
+
} = {}) {
|
|
108
|
+
const name = path.basename(shell);
|
|
109
|
+
if (name === 'zsh') return path.join(zdotdir || home, '.zshrc');
|
|
110
|
+
if (name === 'bash') return path.join(home, platform === 'darwin' ? '.bash_profile' : '.bashrc');
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* `text` with the PATH block for `dir` appended, or unchanged when a block is already there.
|
|
116
|
+
*
|
|
117
|
+
* The dir goes inside double quotes with `\`, `"`, `$` and backtick escaped, so a home path
|
|
118
|
+
* holding any of them stays one literal word to the shell rather than becoming code.
|
|
119
|
+
*/
|
|
120
|
+
export function withPathBlock(text, dir) {
|
|
121
|
+
if (text.includes(RC_BEGIN)) return text;
|
|
122
|
+
const quoted = dir.replace(/[\\"$`]/g, (c) => `\\${c}`);
|
|
123
|
+
const sep = text === '' || text.endsWith('\n') ? '' : '\n';
|
|
124
|
+
return `${text}${sep}${RC_BEGIN}\nexport PATH="${quoted}:$PATH"\n${RC_END}\n`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** `text` without the PATH block `withPathBlock` wrote; unchanged when there is none. */
|
|
128
|
+
export function withoutPathBlock(text) {
|
|
129
|
+
const start = text.indexOf(RC_BEGIN);
|
|
130
|
+
if (start < 0) return text;
|
|
131
|
+
const end = text.indexOf(RC_END, start);
|
|
132
|
+
if (end < 0) return text;
|
|
133
|
+
let stop = end + RC_END.length;
|
|
134
|
+
if (text[stop] === '\n') stop += 1;
|
|
135
|
+
return text.slice(0, start) + text.slice(stop);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Put `dir` on PATH for future shells, ASKING first — macOS zsh does not search
|
|
140
|
+
* `~/.local/bin`, so a linked `geneseed` was not found (reported 2026-10). Only in a terminal:
|
|
141
|
+
* the web console's button and every test run without one, and get the printed line instead,
|
|
142
|
+
* so no gate can edit the developer's own startup file. Returns whether it wrote.
|
|
143
|
+
*/
|
|
144
|
+
function offerShellPath(dir) {
|
|
145
|
+
const rc = shellRcFile();
|
|
146
|
+
if (!rc || !isatty(0)) return false;
|
|
147
|
+
const text = readMaybe(rc) ?? '';
|
|
148
|
+
if (text.includes(RC_BEGIN)) {
|
|
149
|
+
printOut(`geneseed: ${rc} already adds it — open a NEW terminal, then run 'geneseed'.\n`);
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
if (!confirm(`geneseed: add '${dir}' to your PATH in ${rc}?`, true)) return false;
|
|
153
|
+
try {
|
|
154
|
+
writeText(rc, withPathBlock(text, dir));
|
|
155
|
+
} catch (e) {
|
|
156
|
+
printErr(`geneseed: could not write ${rc} (${asOsError(e)})\n`);
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
printOut(`geneseed: added it to ${rc} — open a NEW terminal, then run 'geneseed'.\n`);
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
|
|
90
163
|
/** `_harness_lifecycle._win_bin_dir`. */
|
|
91
164
|
function winBinDir() {
|
|
92
165
|
// `os.environ.get(...) or str(Path.home())` — an EMPTY LOCALAPPDATA falls back too,
|
|
@@ -267,7 +340,7 @@ export function cmdLink(args) {
|
|
|
267
340
|
printOut(`geneseed: linked ${dest} -> ${entry}\n`);
|
|
268
341
|
if ((process.env.PATH || '').split(path.delimiter).includes(targetDir)) {
|
|
269
342
|
printOut(`geneseed: '${targetDir}' is on PATH — run 'geneseed' from anywhere.\n`);
|
|
270
|
-
} else {
|
|
343
|
+
} else if (!offerShellPath(targetDir)) {
|
|
271
344
|
printOut(`geneseed: NOTE '${targetDir}' is not on your PATH. Add it, e.g.:\n`);
|
|
272
345
|
printOut(` echo 'export PATH="${targetDir}:$PATH"' >> ~/.zshrc # or ~/.bashrc\n`);
|
|
273
346
|
}
|
|
@@ -311,6 +384,20 @@ export function cmdUnlink() {
|
|
|
311
384
|
} catch { /* `except OSError: pass` */ }
|
|
312
385
|
}
|
|
313
386
|
}
|
|
387
|
+
// The PATH block `link` may have written, from each startup file it could have chosen.
|
|
388
|
+
const home = os.homedir();
|
|
389
|
+
const rcs = new Set([shellRcFile({ shell: 'zsh' }), path.join(home, '.bashrc'),
|
|
390
|
+
path.join(home, '.bash_profile')]);
|
|
391
|
+
for (const rc of rcs) {
|
|
392
|
+
const text = readMaybe(rc);
|
|
393
|
+
if (text === null || withoutPathBlock(text) === text) continue;
|
|
394
|
+
try {
|
|
395
|
+
writeText(rc, withoutPathBlock(text));
|
|
396
|
+
printOut(`geneseed: removed the PATH line from ${rc}\n`);
|
|
397
|
+
} catch (e) {
|
|
398
|
+
printErr(`geneseed: could not edit ${rc} (${asOsError(e)})\n`);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
314
401
|
if (!removed) printOut('geneseed: no linked launcher found on PATH\n');
|
|
315
402
|
return 0;
|
|
316
403
|
}
|
|
@@ -10,7 +10,7 @@ import path from 'node:path';
|
|
|
10
10
|
import { stripCapabilityLinks } from '../build/emit-common.mjs';
|
|
11
11
|
import { STRUCTURE, renderAll } from '../build/render.mjs';
|
|
12
12
|
import { CONFIG, CREDIT_RE, makeCfg } from '../build/source.mjs';
|
|
13
|
-
import { hostCatalogsNatively, resolvePath } from '../hosts/hosts.mjs';
|
|
13
|
+
import { CLAUDE_STYLE, hostCatalogsNatively, resolvePath } from '../hosts/hosts.mjs';
|
|
14
14
|
import { EMIT_HOST_SCOPE, footprintOfDir, themeFiles } from '../hosts/installs.mjs';
|
|
15
15
|
import { isVendoredPath } from '../hosts/native.mjs';
|
|
16
16
|
import { PALETTE_ROLES, colorThemeFiles } from '../hosts/opencode.mjs';
|
|
@@ -217,7 +217,13 @@ export function renderedProblems(bundle) {
|
|
|
217
217
|
else themeName = 'neutral';
|
|
218
218
|
let emit;
|
|
219
219
|
try { emit = readText(path.join(bundle, '.geneseed-emit')).trim(); } catch { emit = ''; }
|
|
220
|
-
const host =
|
|
220
|
+
const [host, scope] = EMIT_HOST_SCOPE.get(emit) ?? ['', ''];
|
|
221
|
+
// A Claude-shaped PROJECT install is not a bundle: its tree is `AGENTS.md`/`CLAUDE.md` plus a
|
|
222
|
+
// `.bob/`/`.claude/`/`.openclaude/` data dir, so every rendered file reads "missing" and no
|
|
223
|
+
// rebuild can clear it. The console's onboarding once put a Bob project install exactly here
|
|
224
|
+
// (`./Harness` under the checkout, reported 2026-10: 178 such lines). Its own drift is
|
|
225
|
+
// `geneseed diff`'s and `status`'s to report, not this committed-bundle check's.
|
|
226
|
+
if (scope === 'project' && CLAUDE_STYLE.includes(host)) return [];
|
|
221
227
|
let items;
|
|
222
228
|
try {
|
|
223
229
|
// THE FIRST CALLER IN THE PORT THAT CATCHES A REFUSAL, and that is why the stderr is
|
package/js/inspect/cost.mjs
CHANGED
|
@@ -35,7 +35,7 @@ import { isFile, readText } from '../lib/fs.mjs';
|
|
|
35
35
|
* console draws it on the cost chart, so it lives here, once, where both read it. Raise it by
|
|
36
36
|
* exactly the measured overrun and say why in that test's docblock, which keeps its history.
|
|
37
37
|
*/
|
|
38
|
-
export const CARRIER_CEILING = { full:
|
|
38
|
+
export const CARRIER_CEILING = { full: 63_266, lean: 39_628 };
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
41
|
* A frontmatter value as the host shows it. The emit writes descriptions JSON-escaped
|
package/js/inspect/diff.mjs
CHANGED
|
@@ -321,8 +321,38 @@ export function diffCollect({
|
|
|
321
321
|
/** The records that are drift: everything but a `linked` path, which is the user's own. */
|
|
322
322
|
export const drifted = (files) => files.filter((f) => f.status !== 'linked');
|
|
323
323
|
|
|
324
|
+
/** Where the self-improvement loops write, relative to an install's `notebook/`. */
|
|
325
|
+
export const NOTES_DIRS = ['skill-notes', 'agent-notes'];
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* The self-improvement notes an install has accumulated, as `{rel, body}` sorted by `rel`.
|
|
329
|
+
*
|
|
330
|
+
* WHY THESE ARE NOT DRIFT. The loops used to edit the rendered skill and agent files in place,
|
|
331
|
+
* which the doctor reported as stale and the next rebuild erased. They now write a sidecar
|
|
332
|
+
* under the notebook — seed-once and agent-owned, never in the manifest, so no rebuild touches
|
|
333
|
+
* it. That makes the notes the back-port QUEUE rather than an endangered edit: `diff` lists
|
|
334
|
+
* them and `--out` carries them so they can reach `src/`, but the upgrade export does not
|
|
335
|
+
* fire on them alone, because there is nothing for the rebuild to destroy. Empty files are
|
|
336
|
+
* skipped — an agent that cleared its notes has nothing to back-port.
|
|
337
|
+
*/
|
|
338
|
+
export function collectNotes(dir) {
|
|
339
|
+
const notes = [];
|
|
340
|
+
for (const sub of NOTES_DIRS) {
|
|
341
|
+
const d = path.join(dir, 'notebook', sub);
|
|
342
|
+
let names;
|
|
343
|
+
try { names = readdirSync(d); } catch { continue; }
|
|
344
|
+
for (const n of names.filter((x) => x.endsWith('.md')).sort()) {
|
|
345
|
+
const a = path.join(d, n);
|
|
346
|
+
if (!isFile(a)) continue;
|
|
347
|
+
const body = readText(a);
|
|
348
|
+
if (body.trim()) notes.push({ rel: `notebook/${sub}/${n}`, body });
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return notes;
|
|
352
|
+
}
|
|
353
|
+
|
|
324
354
|
/** `_harness_diff._improvements_md` — pure; `when` is the caller's timestamp. */
|
|
325
|
-
export function improvementsMd(target, theme, allFiles, when) {
|
|
355
|
+
export function improvementsMd(target, theme, allFiles, when, notes = []) {
|
|
326
356
|
// A linked path is never a back-port item: the file is the user's, not a local edit of ours.
|
|
327
357
|
const files = drifted(allFiles);
|
|
328
358
|
const edited = files.filter((f) => f.status === 'edited');
|
|
@@ -337,8 +367,8 @@ export function improvementsMd(target, theme, allFiles, when) {
|
|
|
337
367
|
`- ${edited.length} edited · ${added.length} added in deployed · `
|
|
338
368
|
+ `${missing.length} missing from deployed`,
|
|
339
369
|
'',
|
|
340
|
-
'The deployed harness drifted from a fresh render of `src/` — typically
|
|
341
|
-
'
|
|
370
|
+
'The deployed harness drifted from a fresh render of `src/` — typically',
|
|
371
|
+
'agent/skill files edited in place, by hand or by an older harness. Hand this file to',
|
|
342
372
|
'an agent in the Geneseed source repo and ask it to fold the changes below',
|
|
343
373
|
'back into `src/`. Diffs read source -> deployed; the expected copy was',
|
|
344
374
|
'rendered in the deployed theme, so only genuine local edits appear.',
|
|
@@ -352,6 +382,17 @@ export function improvementsMd(target, theme, allFiles, when) {
|
|
|
352
382
|
for (const f of files) {
|
|
353
383
|
lines.push(`## \`${f.rel}\` (${label[f.status]})`, '', '```diff', ...f.diff, '```', '');
|
|
354
384
|
}
|
|
385
|
+
// Only when there are notes, so a report without them stays byte-identical.
|
|
386
|
+
if (notes.length) {
|
|
387
|
+
lines.push('# Self-improvement notes', '',
|
|
388
|
+
'Corrections the agent recorded against a skill or agent instead of editing it.',
|
|
389
|
+
'Fold each one into the matching file under `src/`, then clear the note. Judge it by',
|
|
390
|
+
'its `seen:` evidence — a line without one is a claim, not a lesson — and weigh a',
|
|
391
|
+
'`[general]` line as a law, doctrine or memory rather than an edit to that one file.', '');
|
|
392
|
+
for (const n of notes) {
|
|
393
|
+
lines.push(`## \`${n.rel}\``, '', '```markdown', ...splitLines(n.body), '```', '');
|
|
394
|
+
}
|
|
395
|
+
}
|
|
355
396
|
return `${lines.join('\n')}\n`;
|
|
356
397
|
}
|
|
357
398
|
|
|
@@ -385,7 +426,9 @@ function stamp(d) {
|
|
|
385
426
|
* the same instant, so a run that crosses a second boundary cannot name a file one second
|
|
386
427
|
* away from what it says inside.
|
|
387
428
|
*/
|
|
388
|
-
export function writeImprovements(
|
|
429
|
+
export function writeImprovements(
|
|
430
|
+
target, theme, files, outPath = null, notes = collectNotes(target),
|
|
431
|
+
) {
|
|
389
432
|
const now = stamp(new Date());
|
|
390
433
|
// `Path(out_path).expanduser()` — again NOT resolved, and again observable: the path is
|
|
391
434
|
// printed, so `--out p.md` reports `p.md`.
|
|
@@ -393,7 +436,7 @@ export function writeImprovements(target, theme, files, outPath = null) {
|
|
|
393
436
|
? expanduser(outPath)
|
|
394
437
|
: path.join(target, 'improvements', now.file);
|
|
395
438
|
mkdirSync(path.dirname(p), { recursive: true });
|
|
396
|
-
writeText(p, improvementsMd(target, theme, files, now.when));
|
|
439
|
+
writeText(p, improvementsMd(target, theme, files, now.when, notes));
|
|
397
440
|
return p;
|
|
398
441
|
}
|
|
399
442
|
|
|
@@ -477,18 +520,21 @@ export function cmdDiff(args) {
|
|
|
477
520
|
+ `${missing.length} missing-from-deployed\n`);
|
|
478
521
|
// Its own line, printed only when there is one, so the summary above is unchanged otherwise.
|
|
479
522
|
if (linked.length) printOut(`[diff] ${linked.length} linked (yours, left alone)\n`);
|
|
523
|
+
const notes = collectNotes(target);
|
|
524
|
+
if (notes.length) printOut(`[diff] ${notes.length} self-improvement note(s) to back-port\n`);
|
|
480
525
|
for (const f of edited) printOut(` ~ ${f.rel} (edited in deployed — review to back-port)\n`);
|
|
481
526
|
for (const f of added) printOut(` + ${f.rel} (only in deployed — your addition)\n`);
|
|
482
527
|
for (const f of missing) printOut(` - ${f.rel} (in source, not deployed — re-emit to add)\n`);
|
|
483
528
|
for (const f of linked) printOut(` = ${f.rel} (a link — yours, left alone)\n`);
|
|
529
|
+
for (const n of notes) printOut(` * ${n.rel} (self-improvement note — review to back-port)\n`);
|
|
484
530
|
if (args.out) {
|
|
485
|
-
if (drifted(files).length) {
|
|
531
|
+
if (drifted(files).length || notes.length) {
|
|
486
532
|
// Bare `--out` is argparse's `nargs="?"` const: True, meaning "the default timestamped
|
|
487
533
|
// path". `parse()` gives a flag `true` and an option a string, so the two arrive
|
|
488
534
|
// distinguishable here without a third kind of entry in the VERBS table.
|
|
489
535
|
const outPath = args.out === true ? null : args.out;
|
|
490
536
|
printOut(`[diff] improvements file written: ${
|
|
491
|
-
writeImprovements(target, theme, files, outPath)}\n`);
|
|
537
|
+
writeImprovements(target, theme, files, outPath, notes)}\n`);
|
|
492
538
|
} else {
|
|
493
539
|
printOut('[diff] no differences — nothing written.\n');
|
|
494
540
|
}
|
package/js/inspect/inventory.mjs
CHANGED
|
@@ -97,6 +97,7 @@ export const SKILL_CLASS = {
|
|
|
97
97
|
'frontend-design': 'build', prototype: 'build', deck: 'build',
|
|
98
98
|
'forge-mcp': 'build', bruno: 'build', worktree: 'build',
|
|
99
99
|
'spring-boot': 'build', 'spring-data-jpa': 'build', 'spring-testing': 'build',
|
|
100
|
+
react: 'build', 'react-testing': 'build',
|
|
100
101
|
'geneseed-code-review': 'review', 'fresh-eyes': 'review', 'security-audit': 'review',
|
|
101
102
|
'roast-me': 'review', 'review-response': 'review', ponytail: 'review',
|
|
102
103
|
commit: 'ship', ship: 'ship', release: 'ship', handoff: 'ship',
|
package/js/lib/prompt.mjs
CHANGED
|
@@ -12,6 +12,9 @@ import { readSync } from 'node:fs';
|
|
|
12
12
|
import { printOut } from './fs.mjs';
|
|
13
13
|
import { parseIntStrict } from './text.mjs';
|
|
14
14
|
|
|
15
|
+
/** A cell to `Atomics.wait` on: the synchronous pause between `readLine`'s EAGAIN retries. */
|
|
16
|
+
const PAUSE = new Int32Array(new SharedArrayBuffer(4));
|
|
17
|
+
|
|
15
18
|
/**
|
|
16
19
|
* `_harness_setup._ask` — `input(f"{prompt}{suffix}: ").strip()`, or the default at EOF.
|
|
17
20
|
*
|
|
@@ -71,14 +74,28 @@ export function promptLine(prompt) {
|
|
|
71
74
|
* the wizard's first non-ASCII answer is what exposed it, and the corpus caught it on the
|
|
72
75
|
* first run. Scanning for the terminators a byte at a time is still correct: `\n` and `\r`
|
|
73
76
|
* are ASCII, and no continuation byte of a UTF-8 sequence is below 0x80.
|
|
77
|
+
*
|
|
78
|
+
* EAGAIN IS "NOTHING TYPED YET", NOT EOF. Touching `process.stdin` — even `.isTTY`, which
|
|
79
|
+
* `cmdSetup` reads first — makes libuv put a terminal fd 0 in non-blocking mode, and from then
|
|
80
|
+
* on `readSync` throws EAGAIN until a key is pressed. Caught as EOF, every prompt took its
|
|
81
|
+
* default the instant it printed: setup's "Continue without one? (y/N)" answered itself N and
|
|
82
|
+
* the install stopped before anyone could type (reported 2026-10, reproduced under a pty). So
|
|
83
|
+
* it waits 20 ms and reads again; any other error is still the end of input. `read` is the
|
|
84
|
+
* test seam.
|
|
74
85
|
*/
|
|
75
|
-
function readLine() {
|
|
86
|
+
export function readLine(read = readSync) {
|
|
76
87
|
const buf = Buffer.alloc(1);
|
|
77
88
|
const bytes = [];
|
|
78
89
|
let eof = false;
|
|
79
90
|
for (;;) {
|
|
80
91
|
let n = 0;
|
|
81
|
-
try {
|
|
92
|
+
try {
|
|
93
|
+
n = read(0, buf, 0, 1, null);
|
|
94
|
+
} catch (e) {
|
|
95
|
+
if (e && e.code === 'EAGAIN') { Atomics.wait(PAUSE, 0, 0, 20); continue; }
|
|
96
|
+
eof = true;
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
82
99
|
if (n === 0) { eof = true; break; }
|
|
83
100
|
if (buf[0] === 0x0a) break;
|
|
84
101
|
if (buf[0] !== 0x0d) bytes.push(buf[0]);
|
package/js/maintain/setup.mjs
CHANGED
|
@@ -41,7 +41,8 @@
|
|
|
41
41
|
* a cell could not have varied the answers at all.
|
|
42
42
|
*/
|
|
43
43
|
import { spawnSync } from 'node:child_process';
|
|
44
|
-
import { existsSync } from 'node:fs';
|
|
44
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
45
|
+
import os from 'node:os';
|
|
45
46
|
import path from 'node:path';
|
|
46
47
|
|
|
47
48
|
import { main as driverMain, resolveOut } from '../build/driver.mjs';
|
|
@@ -228,9 +229,11 @@ function askDoctrines(deployed = null) {
|
|
|
228
229
|
}
|
|
229
230
|
|
|
230
231
|
/**
|
|
231
|
-
* The Kits
|
|
232
|
-
* (`deployed`, its `Active kits:` marker)
|
|
233
|
-
*
|
|
232
|
+
* The Kits questions (spec A5): one yes/no per shipped kit, each defaulting to whether the install
|
|
233
|
+
* has it on (`deployed`, its `Active kits:` marker), so holding Enter keeps the installed set. A
|
|
234
|
+
* single pick could not hold two kits at once, which is why the menu became per kit when the second
|
|
235
|
+
* kit shipped. Asked ONLY when this checkout ships a kit, so a kit-less wizard reads exactly the
|
|
236
|
+
* stdin it always did (and has no kit to carry: `kitsOfDir` drops an unshipped one).
|
|
234
237
|
* Returns the kit list, or null for none (`setupBuildArgs` omits `--kits`).
|
|
235
238
|
*/
|
|
236
239
|
export function askKits(deployed = null, kits = null) {
|
|
@@ -239,14 +242,9 @@ export function askKits(deployed = null, kits = null) {
|
|
|
239
242
|
return null;
|
|
240
243
|
}
|
|
241
244
|
const on = (deployed ?? []).filter((k) => kits.has(k));
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
// per kit once a second kit ships.
|
|
246
|
-
if (on.length > 1) opts.push([on.join(','), 'keep the installed kits']);
|
|
247
|
-
opts.push(['none', 'no kit']);
|
|
248
|
-
const pick = askChoice('Kits', opts, on.length ? on.join(',') : 'none');
|
|
249
|
-
return pick === 'none' ? null : pick.split(',');
|
|
245
|
+
const picked = [...kits].filter(([k, m]) => askChoice(`Kit ${k} — ${m.description ?? k}`,
|
|
246
|
+
[['yes', `install the ${k} kit`], ['no', `leave ${k} out`]], on.includes(k) ? 'yes' : 'no') === 'yes');
|
|
247
|
+
return picked.length ? picked.map(([k]) => k) : null;
|
|
250
248
|
}
|
|
251
249
|
|
|
252
250
|
/**
|
|
@@ -521,8 +519,8 @@ export function setupLines() {
|
|
|
521
519
|
doctrines = null, excludeRules = null, excludeSkills = null, gates = {}, kits = null,
|
|
522
520
|
} = sel;
|
|
523
521
|
if (emit === 'opencode-global') {
|
|
524
|
-
// The build below overwrites the deployed global harness,
|
|
525
|
-
//
|
|
522
|
+
// The build below overwrites the deployed global harness, which may carry hand edits (or
|
|
523
|
+
// in-place edits from a harness older than the notebook notes). Preserve that drift first.
|
|
526
524
|
try {
|
|
527
525
|
const [ipath] = exportImprovements();
|
|
528
526
|
if (ipath) {
|
|
@@ -575,26 +573,83 @@ export function cmdSetup() {
|
|
|
575
573
|
}
|
|
576
574
|
|
|
577
575
|
/**
|
|
578
|
-
* The AI coding tools Geneseed installs into, as `[label, present, url]` rows in HOSTS
|
|
576
|
+
* The AI coding tools Geneseed installs into, as `[label, present, url, where]` rows in HOSTS
|
|
577
|
+
* order. `where` is the path found OFF the PATH (see `userInstall`), else null.
|
|
579
578
|
*
|
|
580
|
-
* PRESENT MEANS THE
|
|
581
|
-
*
|
|
582
|
-
*
|
|
583
|
-
*
|
|
584
|
-
*
|
|
579
|
+
* PRESENT MEANS THE TOOL ITSELF WAS FOUND — its command on PATH, or its program in a per-user
|
|
580
|
+
* install location — and never its config dir. That dir looked like a signal — a desktop-only
|
|
581
|
+
* install has one and no command — but GENESEED ITSELF CREATES IT: a `bob-global` emit writes
|
|
582
|
+
* `~/.bob` on a machine that never had Bob, and the first run of this check reported Bob present
|
|
583
|
+
* on exactly such a machine. A wrong "ok" sends the user to an install mode for a tool they
|
|
584
|
+
* cannot open; a wrong "not found" costs a line, and `toolsReport` lets them name the path.
|
|
585
585
|
*
|
|
586
586
|
* NO SPAWN, deliberately. `which` walks PATH by hand, so asking costs nothing on the CLI's
|
|
587
587
|
* child_process allow-list — a `claude --version` probe would have been a third entry there to
|
|
588
|
-
* learn nothing `which` does not already say. `lookup`
|
|
589
|
-
* rely on what this machine happens to have installed.
|
|
588
|
+
* learn nothing `which` does not already say. `lookup` and `userLookup` are the seams for the
|
|
589
|
+
* test, which cannot rely on what this machine happens to have installed.
|
|
590
590
|
*/
|
|
591
|
-
export function hostTools({ lookup = which } = {}) {
|
|
591
|
+
export function hostTools({ lookup = which, userLookup = userInstall } = {}) {
|
|
592
592
|
return [
|
|
593
593
|
['OpenCode', 'opencode', 'https://opencode.ai/docs/'],
|
|
594
594
|
['Claude Code', 'claude', 'https://code.claude.com/docs/en/setup'],
|
|
595
595
|
['IBM Bob', 'bob', 'https://bob.ibm.com/'],
|
|
596
596
|
['OpenClaude', 'openclaude', 'https://openclaude.gitlawb.com/'],
|
|
597
|
-
].map(([label, cmd, url]) =>
|
|
597
|
+
].map(([label, cmd, url]) => {
|
|
598
|
+
if (lookup(cmd)) return [label, true, url, null];
|
|
599
|
+
const where = userLookup(cmd);
|
|
600
|
+
return [label, Boolean(where), url, where || null];
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Per-host off-PATH places: a bin dir a tool's own installer uses, and a name pattern for the
|
|
606
|
+
* desktop app folder. Only Bob ships as a desktop IDE; matching `claude` against app folders
|
|
607
|
+
* would find the Claude desktop chat app, which is not Claude Code.
|
|
608
|
+
*/
|
|
609
|
+
const USER_BIN_DIRS = {
|
|
610
|
+
opencode: ['.opencode/bin'],
|
|
611
|
+
claude: ['.claude/local'],
|
|
612
|
+
bob: [],
|
|
613
|
+
openclaude: [],
|
|
614
|
+
};
|
|
615
|
+
const APP_PATTERN = { bob: /\bbob\b/i };
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Where a tool installed FOR ONE USER lives when its installer did not put it on PATH — the
|
|
619
|
+
* report of 2026-10 was IBM Bob installed under the user's own profile, which `which` never sees.
|
|
620
|
+
*
|
|
621
|
+
* Three kinds of place, each one the tool's own program, never its config dir:
|
|
622
|
+
* * a per-user bin dir: `~/.local/bin` and `~/bin` for every tool, plus the dir its own
|
|
623
|
+
* installer uses (`USER_BIN_DIRS`) — an executable file, through `which` and its PATHEXT;
|
|
624
|
+
* * on Windows, `%APPDATA%\npm` (a per-user `npm i -g`) and a desktop app folder under
|
|
625
|
+
* `%LOCALAPPDATA%\Programs` whose name matches `APP_PATTERN`;
|
|
626
|
+
* * on macOS, a matching `.app` in `/Applications` or `~/Applications`.
|
|
627
|
+
* The first hit wins. Everything a machine could differ on is a parameter, for the test.
|
|
628
|
+
*/
|
|
629
|
+
export function userInstall(cmd, {
|
|
630
|
+
home = os.homedir(), env = process.env, platform = process.platform,
|
|
631
|
+
find = which, list = listDir,
|
|
632
|
+
} = {}) {
|
|
633
|
+
const bins = ['.local/bin', 'bin', ...(USER_BIN_DIRS[cmd] ?? [])].map((d) => path.join(home, d));
|
|
634
|
+
if (platform === 'win32' && env.APPDATA) bins.push(path.join(env.APPDATA, 'npm'));
|
|
635
|
+
const hit = find(cmd, bins.join(path.delimiter));
|
|
636
|
+
if (hit) return hit;
|
|
637
|
+
const pattern = APP_PATTERN[cmd];
|
|
638
|
+
if (!pattern) return null;
|
|
639
|
+
const appDirs = platform === 'win32'
|
|
640
|
+
? (env.LOCALAPPDATA ? [path.join(env.LOCALAPPDATA, 'Programs')] : [])
|
|
641
|
+
: platform === 'darwin' ? ['/Applications', path.join(home, 'Applications')] : [];
|
|
642
|
+
for (const dir of appDirs) {
|
|
643
|
+
const name = list(dir).find((n) => pattern.test(n)
|
|
644
|
+
&& (platform !== 'darwin' || n.endsWith('.app')));
|
|
645
|
+
if (name) return path.join(dir, name);
|
|
646
|
+
}
|
|
647
|
+
return null;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/** `readdirSync`, with a missing or unreadable dir as no entries. */
|
|
651
|
+
function listDir(dir) {
|
|
652
|
+
try { return readdirSync(dir); } catch { return []; }
|
|
598
653
|
}
|
|
599
654
|
|
|
600
655
|
/**
|
|
@@ -602,16 +657,25 @@ export function hostTools({ lookup = which } = {}) {
|
|
|
602
657
|
* answer knowing it — and when NONE is, ask whether to stop and install one first. It never
|
|
603
658
|
* installs one: on a managed machine that is IT's call, and each tool has its own installer.
|
|
604
659
|
* A missing tool among several present is a line, not a question; nobody needs all four.
|
|
660
|
+
* When none is found, the user can name where theirs is before being asked to stop — the
|
|
661
|
+
* search covers PATH and the usual per-user places, not every folder an installer may pick.
|
|
605
662
|
* `false` is the user choosing to stop.
|
|
606
663
|
*/
|
|
607
664
|
export function toolsReport(tools = hostTools()) {
|
|
608
665
|
printOut('AI coding tools on this machine:\n');
|
|
609
|
-
for (const [label, present, url] of tools) {
|
|
610
|
-
|
|
666
|
+
for (const [label, present, url, where] of tools) {
|
|
667
|
+
if (!present) printOut(` -- ${label} not found — install: ${url}\n`);
|
|
668
|
+
else printOut(where ? ` ok ${label} (${where}, not on PATH)\n` : ` ok ${label}\n`);
|
|
611
669
|
}
|
|
612
670
|
if (tools.some(([, present]) => present)) { printOut('\n'); return true; }
|
|
613
|
-
printOut('\nNone of them
|
|
614
|
-
|
|
671
|
+
printOut('\nNone of them was found on PATH or in the usual per-user folders.\n');
|
|
672
|
+
// Quotes stripped: a path dragged into a Windows console arrives wrapped in them.
|
|
673
|
+
const given = ask('Installed somewhere else? Path to the tool (Enter to skip)')
|
|
674
|
+
.replace(/^"(.*)"$/, '$1');
|
|
675
|
+
if (given && existsSync(given)) { printOut(` ok ${given}\n\n`); return true; }
|
|
676
|
+
if (given) printOut(` -- nothing at ${given}\n`);
|
|
677
|
+
printOut('Install the one you use (links above), then run setup again — or go on and pick the '
|
|
678
|
+
+ 'plain bundle, which any AGENT.md tool can read.\n');
|
|
615
679
|
if (confirm('Continue without one?', false)) { printOut('\n'); return true; }
|
|
616
680
|
printOut('Stopped. Run setup again once a tool is installed.\n');
|
|
617
681
|
return false;
|
package/js/maintain/update.mjs
CHANGED
|
@@ -796,8 +796,8 @@ export async function upgrade(ref = null, themeArg = null) {
|
|
|
796
796
|
* `_harness_lifecycle.cmd_upgrade` — the harness verb, which is `export_improvements()` and
|
|
797
797
|
* then `upgrade()`.
|
|
798
798
|
*
|
|
799
|
-
* THE EXPORT RUNS FIRST AND MUST NEVER BLOCK.
|
|
800
|
-
* harness
|
|
799
|
+
* THE EXPORT RUNS FIRST AND MUST NEVER BLOCK. A deployed harness can carry in-place edits (by
|
|
800
|
+
* hand, or by a harness older than the notebook notes) and the rebuild is about to overwrite them, so the drift has to be
|
|
801
801
|
* captured against the PRE-refresh source. Anything it throws is a warning on stderr and the
|
|
802
802
|
* upgrade continues — a failed export is a lost note, a blocked upgrade is a broken install.
|
|
803
803
|
*/
|
package/js/web/actions.mjs
CHANGED
|
@@ -475,6 +475,14 @@ export function buildActionCmds(state, body) {
|
|
|
475
475
|
const [, , ...rest] = installProfile(host, scope, state.root).argv;
|
|
476
476
|
return actionCommands('build', { buildArgv: ['--theme', theme, ...rest] });
|
|
477
477
|
}
|
|
478
|
+
// Only a GLOBAL emit has a home of its own. Any other carries no folder on this route, so the
|
|
479
|
+
// generator fell back to its default `./Harness` — and jobs run with the Geneseed checkout as
|
|
480
|
+
// cwd, so a Bob "project" install landed inside the clone (reported 2026-10), where the doctor's
|
|
481
|
+
// bundle check then read it as 178 missing files. The `deploy` action takes a chosen folder.
|
|
482
|
+
if (!emit.endsWith('-global')) {
|
|
483
|
+
return { error: `${emit} installs into a folder you choose — use Deploy with a folder, `
|
|
484
|
+
+ 'not Build' };
|
|
485
|
+
}
|
|
478
486
|
return actionCommands('build', {
|
|
479
487
|
theme, emit, footprint: state.footprint, posture: state.posture, mode: state.mode,
|
|
480
488
|
trust: state.trust,
|
package/js/web/api.mjs
CHANGED
|
@@ -8,21 +8,21 @@
|
|
|
8
8
|
* bind paths to these functions are `routes.mjs`. This file imports none of its route siblings,
|
|
9
9
|
* which is what keeps the old `api ↔ actions` and `api ↔ activity` cycles from coming back.
|
|
10
10
|
*/
|
|
11
|
-
import { statSync } from 'node:fs';
|
|
11
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
|
|
14
|
-
import { CONFIG, ROOT, THEMES, discoverNames, loadKits, makeCfg } from '../build/source.mjs';
|
|
14
|
+
import { CONFIG, CREDIT_RE, ROOT, SRC, THEMES, discoverNames, loadKits, makeCfg, upstreamOf } from '../build/source.mjs';
|
|
15
15
|
import { readVersion, sourceFingerprint } from '../build/version.mjs';
|
|
16
16
|
import { sourceReleaseVersion } from '../hosts/opencode.mjs';
|
|
17
17
|
import { alwaysOnCost } from '../inspect/cost.mjs';
|
|
18
18
|
import { installProfile } from '../build/generate.mjs';
|
|
19
19
|
import { diffCollect, renderMemo } from '../inspect/diff.mjs';
|
|
20
20
|
import { excludesSnapshot } from '../inspect/excludes.mjs';
|
|
21
|
-
import {
|
|
21
|
+
import { VERSION_MARKER, resolvePath } from '../hosts/hosts.mjs';
|
|
22
22
|
import {
|
|
23
23
|
footprintOfDir, gatesOfDir, installState, installTargets, legacyProcessCarrierOfDir, modeOfDir,
|
|
24
24
|
trustOfDir,
|
|
25
|
-
postureOfDir, readJsonMaybe, themeOfDir,
|
|
25
|
+
postureOfDir, readJsonMaybe, readMaybe, themeOfDir,
|
|
26
26
|
} from '../hosts/installs.mjs';
|
|
27
27
|
import { PRESETS } from '../loop/score.mjs';
|
|
28
28
|
import { loadCatalog, catalogProblems } from '../loop/catalog.mjs';
|
|
@@ -34,7 +34,7 @@ import { comparePaths, normcase } from '../lib/paths.mjs';
|
|
|
34
34
|
import {
|
|
35
35
|
configItems, deployed, dget, memoryItems, notebookItems, wikiItems,
|
|
36
36
|
} from './catalog.mjs';
|
|
37
|
-
import { stampMinute } from './state.mjs';
|
|
37
|
+
import { stampMinute, viewCfg } from './state.mjs';
|
|
38
38
|
import { updateStatus } from './update-check.mjs';
|
|
39
39
|
|
|
40
40
|
/**
|
|
@@ -108,16 +108,9 @@ export function apiDiff(state) {
|
|
|
108
108
|
/** The union across every global install, verbatim. */
|
|
109
109
|
export const apiExcludes = () => excludesSnapshot();
|
|
110
110
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
*/
|
|
115
|
-
export function viewCfg(host, scope, root) {
|
|
116
|
-
if (scope === 'project' && CLAUDE_STYLE.includes(host)) {
|
|
117
|
-
return path.join(root, HOSTS.find((h) => h.host === host).projectMarker);
|
|
118
|
-
}
|
|
119
|
-
return root;
|
|
120
|
-
}
|
|
111
|
+
// `viewCfg` moved to `state.mjs`, which needs it to pick a default view and cannot import
|
|
112
|
+
// this module back (this one imports `stampMinute` from it).
|
|
113
|
+
export { viewCfg } from './state.mjs';
|
|
121
114
|
|
|
122
115
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
123
116
|
|
|
@@ -233,6 +226,31 @@ export function apiFleet(state) {
|
|
|
233
226
|
return { installs, source_fp: sourceFp, doctor: state.doctor };
|
|
234
227
|
}
|
|
235
228
|
|
|
229
|
+
/** Source files of a kit member: `<dir>/<name>.md`, or every `.md` in a `<dir>/<name>/` folder. */
|
|
230
|
+
function memberTexts(dir, name) {
|
|
231
|
+
const one = readMaybe(path.join(SRC, dir, `${name}.md`));
|
|
232
|
+
if (one !== null && one !== undefined) return [one];
|
|
233
|
+
const d = path.join(SRC, dir, name);
|
|
234
|
+
try { return readdirSync(d).filter((f) => f.endsWith('.md')).map((f) => readMaybe(path.join(d, f)) ?? ''); } catch { return []; }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Upstream credits cited by a kit's rules and member sources; an unknown key is skipped, never a 500. */
|
|
238
|
+
function kitCredits(m, rules) {
|
|
239
|
+
const texts = [rules, ...(m.skills ?? []).flatMap((n) => memberTexts('skills', n)),
|
|
240
|
+
...(m.agents ?? []).flatMap((n) => memberTexts('agents', n))];
|
|
241
|
+
const keys = new Set();
|
|
242
|
+
for (const t of texts) for (const x of t.matchAll(CREDIT_RE)) keys.add(x[1]);
|
|
243
|
+
const out = [];
|
|
244
|
+
for (const key of [...keys].sort()) {
|
|
245
|
+
let u;
|
|
246
|
+
try { u = upstreamOf(key); } catch { u = null; }
|
|
247
|
+
if (!u) continue;
|
|
248
|
+
out.push({ key, repo: u.repo, author: u.author, license: u.license, commit: u.commit,
|
|
249
|
+
compareUrl: `https://github.com/${u.repo}/compare/${u.commit}...HEAD` });
|
|
250
|
+
}
|
|
251
|
+
return out;
|
|
252
|
+
}
|
|
253
|
+
|
|
236
254
|
/**
|
|
237
255
|
* EVERY INSTALL'S KITS — the Kits tab. Its own route, like `apiFleet`: it re-reads each carrier
|
|
238
256
|
* and the kit manifests, which the sidebar's `/api/installs` refetch must not pay for.
|
|
@@ -247,8 +265,12 @@ export function apiKits() {
|
|
|
247
265
|
try { known = loadKits(); } catch (e) {
|
|
248
266
|
return { kits: [], installs: [], error: String(e?.message ?? e) };
|
|
249
267
|
}
|
|
250
|
-
const kits = [...known].map(([name, m]) =>
|
|
251
|
-
|
|
268
|
+
const kits = [...known].map(([name, m]) => {
|
|
269
|
+
const rules = readMaybe(path.join(SRC, 'kits', name, 'rules.md')) ?? '';
|
|
270
|
+
return { name, description: m.description ?? '', skills: m.skills ?? [], agents: m.agents ?? [],
|
|
271
|
+
rules, rulesWordBudget: m.rulesWordBudget ?? null, banned: m.banned ?? [],
|
|
272
|
+
credits: kitCredits(m, rules) };
|
|
273
|
+
});
|
|
252
274
|
const installs = [];
|
|
253
275
|
for (const [host, scope, root] of installTargets()) {
|
|
254
276
|
const st = installState(root, host, scope);
|