geneseed 3.9.0 → 3.11.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 +89 -1
- package/DESIGN.md +1 -1
- package/README.md +52 -71
- package/SHIPPED.md +3 -3
- package/adapters/bob/README.md +18 -2
- package/adapters/claude-code/README.md +17 -12
- package/adapters/openclaude/README.md +2 -2
- package/adapters/opencode/README.md +4 -4
- package/adapters/opencode/plugins/geneseed-context.js +78 -8
- package/bin/build-driver.mjs +2 -0
- package/bin/geneseed-cli.mjs +17 -0
- package/bin/geneseed-hook.mjs +3 -1
- package/docs/README.md +42 -0
- package/docs/_groups.json +18 -0
- package/docs/candidate-hosts.md +12 -5
- package/docs/concepts/agents.md +44 -0
- package/docs/concepts/collaboration.md +62 -0
- package/docs/concepts/exclusions.md +55 -0
- package/docs/concepts/footprint.md +52 -0
- package/docs/concepts/foreman-mode.md +69 -0
- package/docs/concepts/hooks.md +101 -0
- package/docs/concepts/hosts.md +55 -0
- package/docs/concepts/lsp.md +50 -0
- package/docs/{web → concepts}/memory.md +2 -2
- package/docs/{web → concepts}/notebook.md +2 -2
- package/docs/concepts/rules.md +52 -0
- package/docs/concepts/skills.md +49 -0
- package/docs/concepts/themes.md +50 -0
- package/docs/extending.md +27 -21
- package/docs/guides/autostart.md +70 -0
- package/docs/guides/choose-your-setup.md +120 -0
- package/docs/guides/headless.md +22 -0
- package/docs/guides/install.md +161 -0
- package/docs/guides/mcp.md +195 -0
- package/docs/guides/migrate.md +41 -0
- package/docs/guides/project-context.md +87 -0
- package/docs/guides/run-anywhere.md +45 -0
- package/docs/guides/uninstall.md +51 -0
- package/docs/guides/upgrade.md +65 -0
- package/docs/guides/verify.md +65 -0
- package/docs/guides/wiki.md +69 -0
- package/docs/guides/worktree.md +47 -0
- package/docs/reference/about.md +7 -0
- package/docs/reference/cli.md +7 -0
- package/docs/reference/environment.md +99 -0
- package/docs/reference/glossary.md +33 -0
- package/docs/reference/opencode-plugins.md +131 -0
- package/docs/reference/troubleshooting.md +350 -0
- package/docs/token-footprint.md +26 -21
- package/docs/understand/a-day.md +77 -0
- package/docs/understand/enforced-vs-asked.md +64 -0
- package/docs/understand/harness.md +36 -0
- package/docs/understand/on-your-machine.md +76 -0
- package/docs/web-ui.md +2 -2
- package/harness.config.json +1 -1
- package/js/README.md +7 -6
- package/js/build/driver.mjs +26 -95
- package/js/build/emit-claude.mjs +76 -81
- package/js/build/generate.mjs +12 -2
- package/js/build/render.mjs +4 -0
- package/js/build/source.mjs +6 -0
- package/js/build/stubs.mjs +18 -2
- package/js/cli-table.json +6 -6
- package/js/hosts/hooks.mjs +86 -54
- package/js/hosts/hosts.mjs +5 -21
- package/js/hosts/installs.mjs +4 -6
- package/js/hosts/mcp.mjs +4 -14
- package/js/hosts/native.mjs +27 -50
- package/js/hosts/opencode.mjs +1 -1
- package/js/hosts/settings.mjs +17 -79
- package/js/inspect/checks-authoring.mjs +9 -4
- package/js/inspect/checks-repo.mjs +45 -1
- package/js/inspect/diff.mjs +11 -3
- package/js/inspect/doctor.mjs +4 -4
- package/js/inspect/excludes.mjs +0 -4
- package/js/inspect/inventory.mjs +1 -1
- package/js/inspect/registry.mjs +20 -3
- package/js/inspect/status.mjs +11 -6
- package/js/lib/node-floor.mjs +30 -0
- package/js/maintain/migrate.mjs +5 -5
- package/js/maintain/setup.mjs +112 -26
- package/js/maintain/uninstall.mjs +8 -42
- package/js/maintain/update.mjs +4 -15
- package/js/ui/cli.mjs +2 -2
- package/js/web/actions.mjs +4 -5
- package/js/web/api.mjs +7 -4
- package/js/web/daemon.mjs +52 -0
- package/js/web/docs.mjs +149 -65
- package/js/web/server.mjs +5 -2
- package/package.json +6 -5
- package/registry.json +7 -0
- package/src/AGENT.md.tmpl +73 -52
- package/src/laws/universal.md +3 -0
- package/src/ontology/universal.md +1 -1
- package/src/postures/mentor.md +1 -0
- package/src/skills/daydream/SKILL.md +10 -3
- package/src/skills/daydream/VENDOR.md +10 -2
- package/src/skills/daydream/instructions.md +2 -2
- package/src/skills/explain-changes/SKILL.md +1 -1
- package/src/skills/explain-changes/VENDOR.md +1 -1
- package/src/skills/geneseed.md +1 -1
- package/src/skills/learn-mode.md +89 -0
- package/src/skills/pipeline.md +2 -2
- package/src/skills/quiz.md +2 -1
- package/src/skills/react-view-transitions/SKILL.md +1 -1
- package/src/skills/react-view-transitions/VENDOR.md +7 -2
- package/src/skills/teach.md +2 -1
- package/src/skills/token-report/SKILL.md +2 -3
- package/src/skills/token-report/VENDOR.md +2 -3
- package/src/skills/token-report/scripts/token_report.mjs +7 -121
- package/src/skills/workflow.md +1 -1
- package/themes/_TEMPLATE.json +1 -0
- package/themes/biker.json +1 -0
- package/themes/commentator.json +1 -0
- package/themes/cyberpunk.json +1 -0
- package/themes/gamer.json +1 -0
- package/themes/imperial.json +1 -0
- package/themes/joker.json +1 -0
- package/themes/marvin.json +1 -0
- package/themes/mean.json +1 -0
- package/themes/military.json +1 -0
- package/themes/neutral.json +1 -0
- package/themes/pirate.json +1 -0
- package/themes/sports.json +1 -0
- package/themes/verstappen.json +1 -0
- package/themes/wizard.json +1 -0
- package/web/dist/assets/Activity-C52LEb-2.js +1 -0
- package/web/dist/assets/ActivityDetail-CjNLRvXg.js +1 -0
- package/web/dist/assets/Dashboard-B6_sSCHi.js +2 -0
- package/web/dist/assets/Docs-Dj7BmCSA.js +5 -0
- package/web/dist/assets/{GreenhouseView-Bq1QmaGO.js → GreenhouseView-BALwjQsA.js} +1 -1
- package/web/dist/assets/Installs-BbffDQm3.js +2 -0
- package/web/dist/assets/JournalView-JpAQ8Hqe.js +1 -0
- package/web/dist/assets/Laws-DK7eCWSm.js +3 -0
- package/web/dist/assets/Library-CoamHelE.js +1 -0
- package/web/dist/assets/{Markdown-CkFyfgNf.js → Markdown-qjM2_ofS.js} +5 -5
- package/web/dist/assets/{OperatorHudView-B-HVBqfY.js → OperatorHudView-DVx1HlBx.js} +1 -1
- package/web/dist/assets/Personal-eaXb1zcj.js +1 -0
- package/web/dist/assets/{Tabs-_pjUFIEI.js → Tabs-BmGtt8E2.js} +1 -1
- package/web/dist/assets/api-Dc8f3ca-.js +1 -0
- package/web/dist/assets/drawable-Du_PZrQj.js +1 -0
- package/web/dist/assets/index-Bz_KMRDH.css +1 -0
- package/web/dist/assets/index-CNFH7o55.js +2 -0
- package/web/dist/index.html +3 -2
- package/QUICKSTART.md +0 -50
- package/SETUP.md +0 -1183
- package/adapters/copilot/README.md +0 -105
- package/docs/web/_groups.json +0 -45
- package/docs/web/about.md +0 -6
- package/docs/web/adapters-claude-code.md +0 -8
- package/docs/web/adapters-opencode-loads.md +0 -8
- package/docs/web/adapters-opencode-spec.md +0 -8
- package/docs/web/adapters-opencode.md +0 -8
- package/docs/web/agents.md +0 -8
- package/docs/web/authoring.md +0 -11
- package/docs/web/autostart.md +0 -9
- package/docs/web/claude-hooks.md +0 -37
- package/docs/web/cli.md +0 -6
- package/docs/web/collaboration.md +0 -27
- package/docs/web/configure-wiki.md +0 -9
- package/docs/web/design.md +0 -7
- package/docs/web/env-knobs.md +0 -9
- package/docs/web/exclusions.md +0 -27
- package/docs/web/first-session.md +0 -28
- package/docs/web/footprint.md +0 -38
- package/docs/web/foreman-mode.md +0 -33
- package/docs/web/glossary.md +0 -6
- package/docs/web/harnesses.md +0 -30
- package/docs/web/headless.md +0 -9
- package/docs/web/ingest-docs.md +0 -9
- package/docs/web/install-paths.md +0 -9
- package/docs/web/install-quick.md +0 -35
- package/docs/web/lsp-overview.md +0 -37
- package/docs/web/mcp-claude-code.md +0 -10
- package/docs/web/mcp-filesystem.md +0 -9
- package/docs/web/mcp-gitlab.md +0 -9
- package/docs/web/mcp-markitdown.md +0 -9
- package/docs/web/mcp-overview.md +0 -34
- package/docs/web/mcp-trouble.md +0 -9
- package/docs/web/mcp-verify.md +0 -9
- package/docs/web/migrate.md +0 -39
- package/docs/web/model.md +0 -20
- package/docs/web/pipelines.md +0 -24
- package/docs/web/plugin-activity.md +0 -23
- package/docs/web/plugin-context.md +0 -26
- package/docs/web/plugin-guard.md +0 -24
- package/docs/web/plugin-learn.md +0 -27
- package/docs/web/plugin-notify.md +0 -24
- package/docs/web/plugin-ponytail.md +0 -23
- package/docs/web/plugin-setup.md +0 -7
- package/docs/web/plugin-workflow.md +0 -25
- package/docs/web/plugins.md +0 -17
- package/docs/web/project-context.md +0 -9
- package/docs/web/rules.md +0 -30
- package/docs/web/run-anywhere.md +0 -9
- package/docs/web/self-improve.md +0 -8
- package/docs/web/setup-choices.md +0 -64
- package/docs/web/skills.md +0 -8
- package/docs/web/themes-author.md +0 -20
- package/docs/web/themes.md +0 -9
- package/docs/web/token-footprint.md +0 -7
- package/docs/web/trouble.md +0 -38
- package/docs/web/upgrade.md +0 -9
- package/docs/web/verify.md +0 -30
- package/docs/web/wiki.md +0 -7
- package/docs/web/worktree.md +0 -10
- package/docs/wiki.md +0 -43
- package/web/dist/assets/Activity-DAzV-qQj.js +0 -1
- package/web/dist/assets/ActivityDetail-CwEwMtKl.js +0 -1
- package/web/dist/assets/Dashboard-C2ELBW-X.js +0 -2
- package/web/dist/assets/Docs-iZrvQP8J.js +0 -2
- package/web/dist/assets/Installs-BiqR8xdO.js +0 -2
- package/web/dist/assets/JournalView-6wilG5Zd.js +0 -1
- package/web/dist/assets/Laws-D8YXD_XE.js +0 -3
- package/web/dist/assets/Library-DwuTZ2-L.js +0 -1
- package/web/dist/assets/Personal-BbUTmZQR.js +0 -1
- package/web/dist/assets/drawable-CoIulEH2.js +0 -1
- package/web/dist/assets/index-BMwSp8BP.js +0 -2
- package/web/dist/assets/index-CDPDk2vj.css +0 -1
package/js/web/actions.mjs
CHANGED
|
@@ -41,7 +41,7 @@ import { excludeAdd, excludeRemove } from '../inspect/excludes.mjs';
|
|
|
41
41
|
import { setupBuildArgs } from '../build/generate.mjs';
|
|
42
42
|
import { frontmatter, memoryDropIndex } from '../hosts/hooks.mjs';
|
|
43
43
|
import {
|
|
44
|
-
HOSTS, bobConfigDir, claudeConfigDir,
|
|
44
|
+
HOSTS, bobConfigDir, claudeConfigDir, expanduser, openclaudeConfigDir,
|
|
45
45
|
opencodeConfigDir, resolvePath,
|
|
46
46
|
} from '../hosts/hosts.mjs';
|
|
47
47
|
import {
|
|
@@ -644,7 +644,7 @@ export function apiMcpToggle(state, body) {
|
|
|
644
644
|
return { ok: false, error: 'config holds comments — edit it by hand to keep them' };
|
|
645
645
|
}
|
|
646
646
|
let cfg;
|
|
647
|
-
if (['claude', 'bob', '
|
|
647
|
+
if (['claude', 'bob', 'openclaude'].includes(host)) {
|
|
648
648
|
if (isFile(p)) {
|
|
649
649
|
let parsed;
|
|
650
650
|
try {
|
|
@@ -816,7 +816,6 @@ const EMIT_FOR = new Map([
|
|
|
816
816
|
['opencode global', 'opencode-global'], ['opencode project', 'opencode'],
|
|
817
817
|
['claude global', 'claude-global'], ['claude project', 'claude'],
|
|
818
818
|
['bob global', 'bob-global'], ['bob project', 'bob'],
|
|
819
|
-
['copilot global', 'copilot-global'], ['copilot project', 'copilot'],
|
|
820
819
|
['openclaude global', 'openclaude-global'], ['openclaude project', 'openclaude'],
|
|
821
820
|
]);
|
|
822
821
|
|
|
@@ -952,7 +951,7 @@ export function apiDeployCmd(state, body) {
|
|
|
952
951
|
return { error: `folder not writable: ${root}` };
|
|
953
952
|
}
|
|
954
953
|
const cfgdirs = new Set();
|
|
955
|
-
for (const fn of [opencodeConfigDir, claudeConfigDir, bobConfigDir,
|
|
954
|
+
for (const fn of [opencodeConfigDir, claudeConfigDir, bobConfigDir,
|
|
956
955
|
openclaudeConfigDir]) {
|
|
957
956
|
try {
|
|
958
957
|
cfgdirs.add(resolvePath(fn()));
|
|
@@ -978,7 +977,7 @@ export function apiDeployCmd(state, body) {
|
|
|
978
977
|
// and `PreToolUse::Bash` went with them. `doctrinesForBuild` resolves unknown to ALL packs.
|
|
979
978
|
const doctrines = bodyDoctrines(body) ?? doctrinesForBuild(root);
|
|
980
979
|
const excludeRules = bodyExcludeRules(body) ?? excludedRulesOfDir(root);
|
|
981
|
-
// project-scope emit name == host name (opencode / claude / bob /
|
|
980
|
+
// project-scope emit name == host name (opencode / claude / bob / openclaude)
|
|
982
981
|
const argv = setupBuildArgs(theme || 'neutral', host, root, root, fp, pos, mode, doctrines,
|
|
983
982
|
PACK_ORDER, excludeRules);
|
|
984
983
|
return { cmd: [process.execPath, path.join(ROOT, 'bin', 'build-driver.mjs'), ...argv] };
|
package/js/web/api.mjs
CHANGED
|
@@ -141,8 +141,8 @@ export function webState(theme = null, target = null) {
|
|
|
141
141
|
* Re-point the console at another detected install's data dir.
|
|
142
142
|
*
|
|
143
143
|
* `root` is the install ROOT the markers and sigils live at. It defaults to `target` and
|
|
144
|
-
* differs only for claude/bob/
|
|
145
|
-
* `<repo>/.claude|.bob|.
|
|
144
|
+
* differs only for claude/bob/openclaude PROJECT installs, where the data sits under
|
|
145
|
+
* `<repo>/.claude|.bob|.openclaude` while `.geneseed-emit`/`-theme`/`-footprint` land at
|
|
146
146
|
* `<repo>/` — reading them from the data dir mis-detects the install as opencode/neutral,
|
|
147
147
|
* and a Diff or a Restore would then overwrite it in the wrong dialect.
|
|
148
148
|
*/
|
|
@@ -725,7 +725,7 @@ export const apiExcludes = () => excludesSnapshot();
|
|
|
725
725
|
* nested-marker host cannot silently read the bare root.
|
|
726
726
|
*/
|
|
727
727
|
export function viewCfg(host, scope, root) {
|
|
728
|
-
if (scope === 'project' && ['claude', 'bob', '
|
|
728
|
+
if (scope === 'project' && ['claude', 'bob', 'openclaude'].includes(host)) {
|
|
729
729
|
return path.join(root, HOSTS.find((h) => h.host === host).projectMarker);
|
|
730
730
|
}
|
|
731
731
|
return root;
|
|
@@ -795,7 +795,7 @@ export function apiOverview(state) {
|
|
|
795
795
|
// can re-emit exactly it. Mirrors `viewCfg`'s rule, spelled out separately here.
|
|
796
796
|
let install = null;
|
|
797
797
|
for (const [host, scope, root] of installTargets()) {
|
|
798
|
-
const data = (scope === 'project' && ['claude', 'bob', '
|
|
798
|
+
const data = (scope === 'project' && ['claude', 'bob', 'openclaude'].includes(host))
|
|
799
799
|
? path.join(root, HOSTS.find((h) => h.host === host).projectMarker) : root;
|
|
800
800
|
try {
|
|
801
801
|
if (samePath(data, state.target)) {
|
|
@@ -819,6 +819,9 @@ export function apiOverview(state) {
|
|
|
819
819
|
install,
|
|
820
820
|
target: state.target,
|
|
821
821
|
deployed: deployed(state),
|
|
822
|
+
// True when the files on disk changed under this server since it started (an upgrade).
|
|
823
|
+
// `serve` installs the comparison; a state built without a server has none and is false.
|
|
824
|
+
daemon_stale: Boolean(state.daemonStale?.()),
|
|
822
825
|
counts: {
|
|
823
826
|
agents: inv.agents.length,
|
|
824
827
|
skills: inv.skills.length,
|
package/js/web/daemon.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import { printOut, writeText } from '../lib/fs.mjs';
|
|
|
13
13
|
import { isTruthy, jsonDumpsCompact } from '../lib/json.mjs';
|
|
14
14
|
import { webState } from './api.mjs';
|
|
15
15
|
import { spawn } from 'node:child_process';
|
|
16
|
+
import { createHash } from 'node:crypto';
|
|
16
17
|
import { chmodSync, mkdirSync, openSync, readFileSync, unlinkSync } from 'node:fs';
|
|
17
18
|
import { join, resolve as pathResolve } from 'node:path';
|
|
18
19
|
|
|
@@ -20,6 +21,29 @@ const ROOT = pathResolve(import.meta.dirname, '..', '..');
|
|
|
20
21
|
|
|
21
22
|
// ---- the daemon record -----------------------------------------------------
|
|
22
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Which code a console serves: the package version and a hash of the `web/dist/index.html` it
|
|
26
|
+
* would load. A daemon writes the stamp it started with into its record; an npm upgrade or a
|
|
27
|
+
* `git pull` that replaces `web/dist/` changes the stamp on disk, and the daemon — still
|
|
28
|
+
* serving the old `index.html`, whose chunk hashes may no longer exist — is then STALE.
|
|
29
|
+
* Read fresh on every call: the comparison is against the files as they are NOW.
|
|
30
|
+
*/
|
|
31
|
+
export function codeStamp(root = ROOT) {
|
|
32
|
+
const read = (rel) => { try { return readFileSync(join(root, rel)); } catch { return ''; } };
|
|
33
|
+
let version = 'unknown';
|
|
34
|
+
try { version = JSON.parse(read('package.json')).version ?? version; } catch { /* unknown */ }
|
|
35
|
+
const html = createHash('sha256').update(read(join('web', 'dist', 'index.html'))).digest('hex');
|
|
36
|
+
return `${version}:${html.slice(0, 12)}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A record written by an older daemon carries no `code` — and is stale by definition. */
|
|
40
|
+
export function daemonStale(record, root = ROOT) {
|
|
41
|
+
return record?.code !== codeStamp(root);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const STALE_LINE = '[web] running OLD code (started before the last upgrade) — '
|
|
45
|
+
+ 'geneseed web restart';
|
|
46
|
+
|
|
23
47
|
const statePath = (target) => join(target, '.geneseed-web.json');
|
|
24
48
|
|
|
25
49
|
export function readDaemon(target) {
|
|
@@ -268,6 +292,7 @@ export async function statusDaemon(theme = null) {
|
|
|
268
292
|
const st = await liveDaemon(target);
|
|
269
293
|
if (st) {
|
|
270
294
|
printOut(`[web] running on ${st.url} (theme: ${none(st.theme)}, pid ${none(st.pid)})\n`);
|
|
295
|
+
if (daemonStale(st)) printOut(`${STALE_LINE}\n`);
|
|
271
296
|
return 0;
|
|
272
297
|
}
|
|
273
298
|
// EXIT 1, which is the verb's whole contract for a script. The snapshot's `<exit>`
|
|
@@ -276,6 +301,33 @@ export async function statusDaemon(theme = null) {
|
|
|
276
301
|
return 1;
|
|
277
302
|
}
|
|
278
303
|
|
|
304
|
+
/**
|
|
305
|
+
* Bounce a running console so it serves the code now on disk — `upgrade` after it rebuilt,
|
|
306
|
+
* `rebuild-all` when the running one is stale. EXCEPT inside a web job (`GENESEED_WEB_JOB`,
|
|
307
|
+
* set by `js/web/jobs.mjs`): restarting here would kill the process tracking that very job,
|
|
308
|
+
* leaving the console stuck on 'running'; the server restarts itself after an update job, and
|
|
309
|
+
* shows its stale banner otherwise. `onlyIfStale` is `rebuild-all`'s: after an `upgrade`
|
|
310
|
+
* (which bounces, then runs `rebuild-all`) the console is current, and bouncing it again
|
|
311
|
+
* would drop the open tab for nothing. Never throws — no rebuild fails over a console.
|
|
312
|
+
*/
|
|
313
|
+
export async function bounceDaemonIfRunning(theme, log, { onlyIfStale = false } = {}) {
|
|
314
|
+
if (onlyIfStale) {
|
|
315
|
+
const st = readDaemon(webState(theme).target);
|
|
316
|
+
if (!st || !daemonStale(st)) return;
|
|
317
|
+
}
|
|
318
|
+
if (process.env.GENESEED_WEB_JOB) {
|
|
319
|
+
log(onlyIfStale
|
|
320
|
+
? '[geneseed] the web console is running old code — use its Restart banner.'
|
|
321
|
+
: '[geneseed] web daemon will restart itself after this job to load the new code.');
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
try {
|
|
325
|
+
await restartDaemon(theme, 4747, false, true);
|
|
326
|
+
} catch (e) {
|
|
327
|
+
log(`[geneseed] ⚠️ could not refresh the web daemon (${e && e.message ? e.message : e}) — \`geneseed web restart\` manually if it was running.`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
279
331
|
export async function restartDaemon(theme = null, port = 4747, openBrowser = true,
|
|
280
332
|
onlyIfRunning = false) {
|
|
281
333
|
const { target } = webState(theme);
|
package/js/web/docs.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The web console's Docs pages, and the registry built from `docs
|
|
2
|
+
* The web console's Docs pages, and the registry built from `docs/<folder>/` (DOC_FOLDERS).
|
|
3
3
|
*
|
|
4
4
|
* `cli` docs render via `cliReference()` in `js/ui/cli.mjs`, which reads
|
|
5
5
|
* `js/cli-table.json` — the CLI's own metadata, since `harness.build_argparser()`'s parser
|
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
* the same file, so there is exactly one description of the CLI surface, not a second
|
|
8
8
|
* transcription of it.
|
|
9
9
|
*
|
|
10
|
-
* `tests/unit/web_server.test.mjs` holds `KINDS` against a written-out list of the
|
|
10
|
+
* `tests/unit/web_server.test.mjs` holds `KINDS` against a written-out list of the six
|
|
11
11
|
* kinds this daemon dispatches on — same declaration-vs-dispatch shape as the route table,
|
|
12
|
-
* which is why it is a table here too and not
|
|
12
|
+
* which is why it is a table here too and not six `if`s.
|
|
13
13
|
*
|
|
14
14
|
* THE `?harness=` QUERY PARAM IS the Docs selector, and it is the ONLY input to these
|
|
15
15
|
* endpoints that is not the checkout itself — the one thing a test can vary. Every
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
import { readdirSync } from 'node:fs';
|
|
19
19
|
import path from 'node:path';
|
|
20
20
|
|
|
21
|
-
import { ROOT, THEMES } from '../build/source.mjs';
|
|
21
|
+
import { DOCS, DOC_FOLDERS, ROOT, THEMES } from '../build/source.mjs';
|
|
22
22
|
import { cliReference } from '../ui/cli.mjs';
|
|
23
23
|
import { readJsonMaybe, readMaybe } from '../hosts/installs.mjs';
|
|
24
24
|
import { resolvePath } from '../hosts/hosts.mjs';
|
|
@@ -30,8 +30,6 @@ import { statusData } from '../inspect/status.mjs';
|
|
|
30
30
|
import { originDisplay } from '../maintain/update.mjs';
|
|
31
31
|
import { NotFound, deployed, resolveLinks } from './api.mjs';
|
|
32
32
|
|
|
33
|
-
const DOC_DIR = path.join(ROOT, 'docs', 'web');
|
|
34
|
-
|
|
35
33
|
/**
|
|
36
34
|
* A docs page split into its frontmatter map and its body.
|
|
37
35
|
*
|
|
@@ -61,24 +59,45 @@ function docFrontmatter(text) {
|
|
|
61
59
|
}
|
|
62
60
|
|
|
63
61
|
/**
|
|
64
|
-
*
|
|
62
|
+
* Every docs page source as `{ id, rel }`, `rel` relative to ROOT with forward slashes.
|
|
63
|
+
*
|
|
64
|
+
* The id is the basename because it is the router's address (`#/docs/<id>`); the folder is
|
|
65
|
+
* GitHub's business, not the console's. Two pages with one basename would leave one of them
|
|
66
|
+
* unreachable, so a duplicate throws instead of shadowing.
|
|
67
|
+
*/
|
|
68
|
+
export function docSources(docRoot = DOCS) {
|
|
69
|
+
const out = [];
|
|
70
|
+
const seen = new Set();
|
|
71
|
+
for (const folder of DOC_FOLDERS) {
|
|
72
|
+
let names;
|
|
73
|
+
try { names = readdirSync(path.join(docRoot, folder)); } catch { continue; }
|
|
74
|
+
for (const name of names.filter((n) => normcase(n).endsWith('.md')).sort()) {
|
|
75
|
+
const id = name.slice(0, -3);
|
|
76
|
+
if (seen.has(id)) throw new Error(`duplicate docs page id: ${id}`);
|
|
77
|
+
seen.add(id);
|
|
78
|
+
out.push({ id, rel: `docs/${folder}/${name}` });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The Docs registry, built from `docSources()` and `docs/_groups.json`.
|
|
65
86
|
*
|
|
66
87
|
* A page whose file is missing or malformed is skipped rather than crashing the server; an
|
|
67
88
|
* unreadable registry yields an empty Docs section, which the UI renders as "no pages".
|
|
68
89
|
*
|
|
69
|
-
* NOT cached at module load. The difference is invisible to a request —
|
|
90
|
+
* NOT cached at module load. The difference is invisible to a request — the docs tree cannot
|
|
70
91
|
* change under a running daemon any more than `dist/` can — and a module-level constant
|
|
71
92
|
* would freeze whatever `ROOT` was when this file was first imported.
|
|
72
93
|
*/
|
|
73
94
|
export function docGroups() {
|
|
74
|
-
const groups = readJsonMaybe(path.join(
|
|
95
|
+
const groups = readJsonMaybe(path.join(DOCS, '_groups.json'));
|
|
75
96
|
if (!Array.isArray(groups)) return [];
|
|
76
97
|
const byId = new Map();
|
|
77
98
|
for (const g of groups) byId.set(g.id, { ...g, pages: [] });
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
for (const name of names.filter((n) => normcase(n).endsWith('.md')).sort()) {
|
|
81
|
-
const text = readMaybe(path.join(DOC_DIR, name));
|
|
99
|
+
for (const { id, rel } of docSources()) {
|
|
100
|
+
const text = readMaybe(path.join(ROOT, rel));
|
|
82
101
|
if (text === null) continue;
|
|
83
102
|
const [meta, body] = docFrontmatter(text);
|
|
84
103
|
const group = byId.get(meta.group);
|
|
@@ -86,7 +105,7 @@ export function docGroups() {
|
|
|
86
105
|
if (group === undefined) continue;
|
|
87
106
|
const order = Object.hasOwn(meta, 'order') ? meta.order : 0;
|
|
88
107
|
delete meta.order;
|
|
89
|
-
const page = { id
|
|
108
|
+
const page = { id, rel, ...meta };
|
|
90
109
|
if (body.trim()) page.body = body.replace(/\n+$/, '');
|
|
91
110
|
group.pages.push([order, page]);
|
|
92
111
|
}
|
|
@@ -103,50 +122,96 @@ export function docGroups() {
|
|
|
103
122
|
return out;
|
|
104
123
|
}
|
|
105
124
|
|
|
125
|
+
// ---- markdown tables as data ------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
/** One table row's cells; a `\|` inside a cell is kept as a literal bar. */
|
|
128
|
+
function tableCells(line) {
|
|
129
|
+
return line.trim().replace(/^\|/, '').replace(/\|$/, '').split(/(?<!\\)\|/)
|
|
130
|
+
.map((c) => c.trim().replace(/\\\|/g, '|'));
|
|
131
|
+
}
|
|
132
|
+
|
|
106
133
|
/**
|
|
107
|
-
* The
|
|
108
|
-
*
|
|
134
|
+
* The body rows of the first table whose header, lower-cased, is exactly `head` — or `null`.
|
|
135
|
+
*
|
|
136
|
+
* A TABLE IS THE DATA so a page reads the same on GitHub as in the console: the map on
|
|
137
|
+
* understand page 2 and the glossary are both tables a person can read, and the console builds
|
|
138
|
+
* its richer view from the same rows rather than from a second copy that could drift. A row
|
|
139
|
+
* with the wrong number of cells is a malformed table, so the whole answer is `null`.
|
|
109
140
|
*/
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
]
|
|
141
|
+
export function tableRows(body, head) {
|
|
142
|
+
const lines = body.split('\n');
|
|
143
|
+
for (let i = 0; i + 1 < lines.length; i += 1) {
|
|
144
|
+
if (!lines[i].trim().startsWith('|')) continue;
|
|
145
|
+
const h = tableCells(lines[i]).map((c) => c.toLowerCase());
|
|
146
|
+
if (h.length !== head.length || h.some((c, j) => c !== head[j])) continue;
|
|
147
|
+
const rows = [];
|
|
148
|
+
for (let j = i + 2; j < lines.length && lines[j].trim().startsWith('|'); j += 1) {
|
|
149
|
+
const row = tableCells(lines[j]);
|
|
150
|
+
if (row.length !== head.length) return null;
|
|
151
|
+
rows.push(row);
|
|
152
|
+
}
|
|
153
|
+
return rows;
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const MAP_HEAD = ['piece', 'what it does for you', 'kind', 'cost', 'turn it off'];
|
|
159
|
+
export const MAP_KINDS = ['enforced', 'automatic', 'asked', 'on demand'];
|
|
160
|
+
|
|
161
|
+
/** The "what lands on your machine" table as rows, or `null` when absent or malformed. */
|
|
162
|
+
export function parseMapTable(body) {
|
|
163
|
+
const rows = tableRows(body, MAP_HEAD);
|
|
164
|
+
if (!rows || !rows.length) return null;
|
|
165
|
+
const out = rows.map(([piece, does, kind, cost, off]) => (
|
|
166
|
+
{ piece, does, kind: kind.toLowerCase(), cost, off }));
|
|
167
|
+
return out.every((r) => MAP_KINDS.includes(r.kind)) ? out : null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const GLOSSARY_HEAD = ['term', 'theme key', 'meaning', 'in dev terms'];
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The glossary table: term, the theme key whose value renames it (`—` when no theme does),
|
|
174
|
+
* the meaning, and the developer analogy (`—` when there is none).
|
|
175
|
+
*/
|
|
176
|
+
export function parseGlossaryTable(body) {
|
|
177
|
+
const rows = tableRows(body, GLOSSARY_HEAD);
|
|
178
|
+
if (!rows) return [];
|
|
179
|
+
const dash = (s) => (s === '—' || s === '-' || s === '' ? null : s);
|
|
180
|
+
return rows.map(([label, key, desc, analogy]) => (
|
|
181
|
+
{ label, key: dash(key), desc, analogy: dash(analogy) }));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---- links between pages ----------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
const MD_LINK_RE = /\]\((?!https?:|#|mailto:)([^)\s]+?\.md)(#[^)\s]*)?\)/g;
|
|
187
|
+
// ponytail: the canonical repo, not the install's origin; a fork's console links upstream.
|
|
188
|
+
const GITHUB_BLOB = 'https://github.com/Arylmera/Geneseed/blob/main/';
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Relative `.md` links, which is how the pages link on GitHub, rewritten for the console.
|
|
192
|
+
*
|
|
193
|
+
* A target that is a docs page becomes `#/docs/<id>` (its `#anchor` dropped — the console
|
|
194
|
+
* addresses pages, not headings); any other repo file — a maintainer doc, a README under
|
|
195
|
+
* `src/` — opens on GitHub, since the console does not serve it. Absolute URLs, in-page
|
|
196
|
+
* anchors and fenced examples are left alone.
|
|
197
|
+
*/
|
|
198
|
+
export function rewriteDocLinks(body, fromRel, idByRel) {
|
|
199
|
+
if (!body.includes('.md')) return body;
|
|
200
|
+
let inFence = false;
|
|
201
|
+
return body.split('\n').map((line) => {
|
|
202
|
+
if (line.startsWith('```')) { inFence = !inFence; return line; }
|
|
203
|
+
if (inFence) return line;
|
|
204
|
+
return line.replace(MD_LINK_RE, (m, target, anchor) => {
|
|
205
|
+
const resolved = path.posix.normalize(path.posix.join(path.posix.dirname(fromRel), target));
|
|
206
|
+
const id = idByRel.get(resolved);
|
|
207
|
+
return id ? `](#/docs/${id})` : `](${GITHUB_BLOB}${resolved}${anchor ?? ''})`;
|
|
208
|
+
});
|
|
209
|
+
}).join('\n');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function idsByRel() {
|
|
213
|
+
return new Map(docSources().map((s) => [s.rel, s.id]));
|
|
214
|
+
}
|
|
150
215
|
|
|
151
216
|
// ---- counts substituted into concept bodies -------------------------------------------
|
|
152
217
|
|
|
@@ -239,6 +304,13 @@ export function harnessBlocksBalanced(lines) {
|
|
|
239
304
|
return !open;
|
|
240
305
|
}
|
|
241
306
|
|
|
307
|
+
/**
|
|
308
|
+
* The visible `*(OpenCode only)*` line a block opens with. It exists for GitHub, which hides
|
|
309
|
+
* the marker; the console has already filtered the block to the reader's host, so there the
|
|
310
|
+
* label only repeats what the host selector says, and it goes with the marker.
|
|
311
|
+
*/
|
|
312
|
+
const HOST_LABEL_RE = /^\s*\*\((OpenCode|Claude Code) only\)\*\s*$/;
|
|
313
|
+
|
|
242
314
|
export function stripHarnessBlocks(body, harnessName) {
|
|
243
315
|
if (!HARNESS_HINT_RE.test(body)) return body;
|
|
244
316
|
const lines = splitLines(body);
|
|
@@ -246,7 +318,10 @@ export function stripHarnessBlocks(body, harnessName) {
|
|
|
246
318
|
const out = [];
|
|
247
319
|
let keep = true;
|
|
248
320
|
let inFence = false;
|
|
321
|
+
let afterOpen = false;
|
|
249
322
|
for (const line of lines) {
|
|
323
|
+
const labelSlot = afterOpen;
|
|
324
|
+
afterOpen = false;
|
|
250
325
|
if (line.startsWith('```')) {
|
|
251
326
|
inFence = !inFence;
|
|
252
327
|
if (keep) out.push(line);
|
|
@@ -254,8 +329,9 @@ export function stripHarnessBlocks(body, harnessName) {
|
|
|
254
329
|
}
|
|
255
330
|
if (!inFence) {
|
|
256
331
|
const m = HARNESS_OPEN_RE.exec(line);
|
|
257
|
-
if (m) { keep = m[1] === harnessName; continue; }
|
|
332
|
+
if (m) { keep = m[1] === harnessName; afterOpen = true; continue; }
|
|
258
333
|
if (HARNESS_CLOSE_RE.test(line)) { keep = true; continue; }
|
|
334
|
+
if (labelSlot && HOST_LABEL_RE.test(line)) continue;
|
|
259
335
|
}
|
|
260
336
|
if (keep) out.push(line);
|
|
261
337
|
}
|
|
@@ -363,8 +439,8 @@ function readDocSource(rel) {
|
|
|
363
439
|
return readMaybe(target) ?? '';
|
|
364
440
|
}
|
|
365
441
|
|
|
366
|
-
/** The deployed theme's words beside the neutral ones. */
|
|
367
|
-
function glossary(state) {
|
|
442
|
+
/** The deployed theme's words beside the neutral ones, from `docs/reference/glossary.md`. */
|
|
443
|
+
function glossary(state, page) {
|
|
368
444
|
const load = (theme) => {
|
|
369
445
|
const doc = readJsonMaybe(path.join(THEMES, `${theme}.json`));
|
|
370
446
|
return doc && typeof doc === 'object' && !Array.isArray(doc) ? doc : {};
|
|
@@ -372,10 +448,10 @@ function glossary(state) {
|
|
|
372
448
|
const neutral = load('neutral');
|
|
373
449
|
const themed = state.theme !== 'neutral' ? load(state.theme) : neutral;
|
|
374
450
|
const rows = [];
|
|
375
|
-
for (const
|
|
451
|
+
for (const { label, key, desc, analogy } of parseGlossaryTable(page.body ?? '')) {
|
|
376
452
|
if (key === null) {
|
|
377
453
|
const term = label.toLowerCase();
|
|
378
|
-
rows.push({ label, neutral: term, themed: term, desc });
|
|
454
|
+
rows.push({ label, neutral: term, themed: term, desc, analogy });
|
|
379
455
|
continue;
|
|
380
456
|
}
|
|
381
457
|
rows.push({
|
|
@@ -383,6 +459,7 @@ function glossary(state) {
|
|
|
383
459
|
neutral: String(Object.hasOwn(neutral, key) ? neutral[key] : '').trim(),
|
|
384
460
|
themed: String(Object.hasOwn(themed, key) ? themed[key] : '').trim(),
|
|
385
461
|
desc,
|
|
462
|
+
analogy,
|
|
386
463
|
});
|
|
387
464
|
}
|
|
388
465
|
return { theme: state.theme, rows };
|
|
@@ -452,7 +529,7 @@ export function apiDocsPage(state, pageId, harnessName = null) {
|
|
|
452
529
|
}
|
|
453
530
|
|
|
454
531
|
/**
|
|
455
|
-
* The
|
|
532
|
+
* The six kinds, as a TABLE and not six `if`s.
|
|
456
533
|
*
|
|
457
534
|
* `tests/unit/web_server.test.mjs` holds `Object.keys(KIND_ROUTES)` against a written-out
|
|
458
535
|
* list of the kinds `apiDocsPage` must dispatch on — this table is what the dispatcher
|
|
@@ -470,17 +547,24 @@ const KIND_ROUTES = {
|
|
|
470
547
|
body = sliced;
|
|
471
548
|
if (ok) anchor = null;
|
|
472
549
|
}
|
|
473
|
-
body = stripHarnessBlocks(body, hn);
|
|
550
|
+
body = rewriteDocLinks(stripHarnessBlocks(body, hn), page.source, idsByRel());
|
|
474
551
|
return { id: pageId, title: page.title, kind: 'markdown', body,
|
|
475
552
|
source: page.source, anchor, links: resolveLinks(state, body) };
|
|
476
553
|
},
|
|
477
554
|
concept: (state, pageId, page, hn) => {
|
|
478
|
-
const body = subCounts(state, stripHarnessBlocks(page.body ?? '', hn))
|
|
555
|
+
const body = rewriteDocLinks(subCounts(state, stripHarnessBlocks(page.body ?? '', hn)),
|
|
556
|
+
page.rel, idsByRel());
|
|
479
557
|
return { id: pageId, title: page.title, kind: 'concept', body,
|
|
480
558
|
link: page.link ?? null, links: resolveLinks(state, body) };
|
|
481
559
|
},
|
|
560
|
+
// A concept page plus the rows of its own map table, for the console's map view.
|
|
561
|
+
map: (state, pageId, page, hn) => {
|
|
562
|
+
const body = rewriteDocLinks(stripHarnessBlocks(page.body ?? '', hn), page.rel, idsByRel());
|
|
563
|
+
return { id: pageId, title: page.title, kind: 'map', body, rows: parseMapTable(body),
|
|
564
|
+
links: resolveLinks(state, body) };
|
|
565
|
+
},
|
|
482
566
|
glossary: (state, pageId, page) => ({
|
|
483
|
-
id: pageId, title: page.title, kind: 'glossary', ...glossary(state),
|
|
567
|
+
id: pageId, title: page.title, kind: 'glossary', ...glossary(state, page),
|
|
484
568
|
}),
|
|
485
569
|
about: (state, pageId, page) => ({
|
|
486
570
|
id: pageId, title: page.title, kind: 'about', ...about(state),
|
package/js/web/server.mjs
CHANGED
|
@@ -28,7 +28,7 @@ import { which } from '../lib/paths.mjs';
|
|
|
28
28
|
import { parseIntStrict } from '../lib/text.mjs';
|
|
29
29
|
import { promptLine } from '../maintain/setup.mjs';
|
|
30
30
|
import { webState } from './api.mjs';
|
|
31
|
-
import { clearDaemon, openUrl, readDaemon, restartDaemon, startDaemon, statusDaemon, stopDaemon, writeDaemon } from './daemon.mjs';
|
|
31
|
+
import { clearDaemon, codeStamp, openUrl, readDaemon, restartDaemon, startDaemon, statusDaemon, stopDaemon, writeDaemon } from './daemon.mjs';
|
|
32
32
|
import { isFile, makeHandler } from './handler.mjs';
|
|
33
33
|
import { JobManager } from './jobs.mjs';
|
|
34
34
|
import { spawnSync } from 'node:child_process';
|
|
@@ -137,6 +137,9 @@ export async function serve({ theme = null, port = 4747, openBrowser = true,
|
|
|
137
137
|
printOut(' Run `geneseed setup` first — serving anyway (read-only UI).\n');
|
|
138
138
|
}
|
|
139
139
|
const token = randomBytes(24).toString('base64url');
|
|
140
|
+
// The code this server loaded, compared on every overview against the files on disk now.
|
|
141
|
+
const startCode = codeStamp();
|
|
142
|
+
state.daemonStale = () => startCode !== codeStamp();
|
|
140
143
|
const holder = {};
|
|
141
144
|
// The console's job list survives a reload and a restart because it is a FILE on disk,
|
|
142
145
|
// not held in process memory.
|
|
@@ -156,7 +159,7 @@ export async function serve({ theme = null, port = 4747, openBrowser = true,
|
|
|
156
159
|
if (daemon) {
|
|
157
160
|
writeDaemon(state.target, {
|
|
158
161
|
pid: process.pid, port: hostPort, url,
|
|
159
|
-
token, theme: state.theme, started: Math.floor(Date.now() / 1000),
|
|
162
|
+
token, theme: state.theme, started: Math.floor(Date.now() / 1000), code: startCode,
|
|
160
163
|
});
|
|
161
164
|
}
|
|
162
165
|
printOut(`[web] Geneseed UI on ${url} (theme: ${state.theme})\n`);
|
package/package.json
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"//": "Every field here is argued and gated in tests/unit/package_manifest.test.mjs. `files`, `bin` and `engines` each have a test that fails when reality drifts from this file; `version` mirrors harness.config.json and is gated too. Read that docblock before editing this one.",
|
|
3
3
|
"name": "geneseed",
|
|
4
|
-
"version": "3.
|
|
4
|
+
"version": "3.11.0",
|
|
5
5
|
"description": "A portable, theme-able harness you implant once and use everywhere to grow a disciplined AI coding agent.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"ai",
|
|
8
8
|
"agent",
|
|
9
9
|
"claude-code",
|
|
10
10
|
"opencode",
|
|
11
|
-
"copilot",
|
|
12
11
|
"openclaude",
|
|
13
12
|
"agents-md",
|
|
14
13
|
"skills",
|
|
@@ -39,7 +38,11 @@
|
|
|
39
38
|
"themes/",
|
|
40
39
|
"adapters/",
|
|
41
40
|
"docs/*.md",
|
|
42
|
-
"docs/
|
|
41
|
+
"docs/_groups.json",
|
|
42
|
+
"docs/understand/",
|
|
43
|
+
"docs/guides/",
|
|
44
|
+
"docs/concepts/",
|
|
45
|
+
"docs/reference/",
|
|
43
46
|
"web/dist/",
|
|
44
47
|
"web/src/pages/Laws.jsx",
|
|
45
48
|
"harness.config.json",
|
|
@@ -48,8 +51,6 @@
|
|
|
48
51
|
"geneseed.cmd",
|
|
49
52
|
"CHANGELOG.md",
|
|
50
53
|
"DESIGN.md",
|
|
51
|
-
"QUICKSTART.md",
|
|
52
|
-
"SETUP.md",
|
|
53
54
|
"SHIPPED.md"
|
|
54
55
|
],
|
|
55
56
|
"devDependencies": {
|
package/registry.json
CHANGED
|
@@ -295,6 +295,13 @@
|
|
|
295
295
|
"added": "2026-06-07",
|
|
296
296
|
"last_verified": "2026-09-06"
|
|
297
297
|
},
|
|
298
|
+
"skills/learn-mode": {
|
|
299
|
+
"status": "experimental",
|
|
300
|
+
"version": "1.0.0",
|
|
301
|
+
"owner": "Guillaume Lemer",
|
|
302
|
+
"added": "2026-10-02",
|
|
303
|
+
"last_verified": ""
|
|
304
|
+
},
|
|
298
305
|
"skills/migrate": {
|
|
299
306
|
"status": "approved",
|
|
300
307
|
"version": "1.0.0",
|