geneseed 3.11.0 → 3.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +160 -0
- package/README.md +5 -5
- package/SHIPPED.md +2 -2
- package/adapters/bob/README.md +1 -1
- package/adapters/claude-code/README.md +1 -1
- package/adapters/openclaude/README.md +1 -1
- package/adapters/opencode/README.md +3 -3
- package/adapters/opencode/plugins/geneseed-guard.js +157 -2
- package/bin/geneseed-cli.mjs +4 -0
- package/docs/README.md +41 -22
- package/docs/concepts/agents.md +3 -1
- package/docs/concepts/collaboration.md +3 -1
- package/docs/concepts/exclusions.md +4 -2
- package/docs/concepts/footprint.md +3 -1
- package/docs/concepts/foreman-mode.md +4 -2
- package/docs/concepts/hook-gates.md +61 -0
- package/docs/concepts/hooks.md +8 -56
- package/docs/concepts/host-matrix.md +38 -0
- package/docs/concepts/hosts.md +6 -32
- package/docs/concepts/loop-branch.md +66 -0
- package/docs/concepts/loop-bricks.md +48 -0
- package/docs/concepts/loop-contracts.md +33 -0
- package/docs/concepts/loop-gates.md +30 -0
- package/docs/concepts/loop-notes.md +43 -0
- package/docs/concepts/loop-risk.md +36 -0
- package/docs/concepts/loop-templates.md +50 -0
- package/docs/concepts/loops.md +73 -0
- package/docs/concepts/lsp.md +2 -0
- package/docs/concepts/memory.md +3 -1
- package/docs/concepts/notebook.md +3 -1
- package/docs/concepts/rules.md +4 -2
- package/docs/concepts/skills.md +3 -1
- package/docs/concepts/themes.md +3 -1
- package/docs/extending.md +28 -8
- package/docs/guides/autostart.md +3 -1
- package/docs/guides/choose-your-setup.md +5 -66
- package/docs/guides/headless.md +5 -3
- package/docs/guides/install-agent-md.md +37 -0
- package/docs/guides/install-bob.md +20 -0
- package/docs/guides/install-claude-code.md +22 -0
- package/docs/guides/install-clone.md +34 -0
- package/docs/guides/install-openclaude.md +25 -0
- package/docs/guides/install-opencode.md +35 -0
- package/docs/guides/install-scripted.md +21 -0
- package/docs/guides/install.md +9 -118
- package/docs/guides/loop-finish.md +54 -0
- package/docs/guides/loop-running.md +66 -0
- package/docs/guides/loop-which-template.md +35 -0
- package/docs/guides/mcp-filesystem.md +41 -0
- package/docs/guides/mcp-gitlab.md +66 -0
- package/docs/guides/mcp-markitdown.md +36 -0
- package/docs/guides/mcp.md +8 -137
- package/docs/guides/migrate.md +3 -1
- package/docs/guides/project-context.md +4 -2
- package/docs/guides/read-non-markdown.md +17 -0
- package/docs/guides/run-a-loop.md +64 -0
- package/docs/guides/run-anywhere.md +2 -0
- package/docs/guides/setup-doctrines.md +55 -0
- package/docs/guides/setup-footprint.md +48 -0
- package/docs/guides/uninstall.md +3 -1
- package/docs/guides/upgrade.md +3 -1
- package/docs/guides/verify.md +3 -1
- package/docs/guides/wiki.md +3 -1
- package/docs/guides/worktree.md +3 -1
- package/docs/reference/about.md +3 -1
- package/docs/reference/cli.md +2 -0
- package/docs/reference/env-context.md +40 -0
- package/docs/reference/environment.md +10 -59
- package/docs/reference/glossary.md +7 -1
- package/docs/reference/loop-actions.md +20 -0
- package/docs/reference/opencode-plugins.md +14 -116
- package/docs/reference/plugin-activity.md +20 -0
- package/docs/reference/plugin-context.md +27 -0
- package/docs/reference/plugin-guard.md +27 -0
- package/docs/reference/plugin-learn.md +27 -0
- package/docs/reference/plugin-notify.md +20 -0
- package/docs/reference/plugin-ponytail.md +19 -0
- package/docs/reference/plugin-workflow.md +25 -0
- package/docs/reference/troubleshoot-doctor.md +50 -0
- package/docs/reference/troubleshoot-hooks.md +99 -0
- package/docs/reference/troubleshoot-install.md +108 -0
- package/docs/reference/troubleshoot-tools.md +56 -0
- package/docs/reference/troubleshoot-update.md +23 -0
- package/docs/reference/troubleshoot-web.md +66 -0
- package/docs/reference/troubleshooting.md +13 -337
- package/docs/understand/a-day.md +3 -1
- package/docs/understand/enforced-vs-asked.md +3 -1
- package/docs/understand/harness.md +2 -0
- package/docs/understand/machine-bob.md +17 -0
- package/docs/understand/machine-claude.md +35 -0
- package/docs/understand/machine-opencode.md +36 -0
- package/docs/understand/on-your-machine.md +12 -55
- package/docs/understand/take-it-out.md +13 -0
- package/docs/web-ui.md +36 -5
- package/harness.config.json +1 -1
- package/js/README.md +32 -2
- package/js/build/driver.mjs +14 -8
- package/js/build/generate.mjs +10 -6
- package/js/build/render.mjs +5 -0
- package/js/build/source.mjs +5 -1
- package/js/cli-table.json +261 -0
- package/js/hosts/gitref.mjs +99 -0
- package/js/hosts/hooks.mjs +88 -1
- package/js/hosts/installs.mjs +21 -0
- package/js/hosts/mcp.mjs +1 -1
- package/js/hosts/native.mjs +43 -5
- package/js/inspect/checks-authoring.mjs +10 -0
- package/js/inspect/checks-build.mjs +28 -0
- package/js/inspect/diff.mjs +3 -0
- package/js/inspect/doctor.mjs +2 -1
- package/js/inspect/inventory.mjs +1 -0
- package/js/inspect/status.mjs +1 -1
- package/js/loop/catalog.mjs +127 -0
- package/js/loop/cli.mjs +173 -0
- package/js/loop/graph.mjs +163 -0
- package/js/loop/registry.mjs +196 -0
- package/js/loop/score.mjs +182 -0
- package/js/loop/state.mjs +436 -0
- package/js/maintain/migrate.mjs +4 -2
- package/js/maintain/setup.mjs +1 -1
- package/js/web/actions.mjs +34 -3
- package/js/web/api.mjs +51 -2
- package/js/web/daemon.mjs +43 -5
- package/js/web/docs.mjs +64 -12
- package/js/web/handler.mjs +1 -0
- package/js/web/jobs.mjs +3 -1
- package/js/web/routes.mjs +5 -1
- package/js/web/server.mjs +5 -3
- package/package.json +1 -1
- package/registry.json +14 -0
- package/src/AGENT.md.tmpl +2 -0
- package/src/agents/explorer.md +2 -2
- package/src/agents/researcher.md +3 -3
- package/src/bricks/README.md +34 -0
- package/src/bricks/_template.md +32 -0
- package/src/bricks/adr-challenge.md +20 -0
- package/src/bricks/adr-draft.md +15 -0
- package/src/bricks/apply.md +17 -0
- package/src/bricks/baseline-green.md +17 -0
- package/src/bricks/bruno-test.md +16 -0
- package/src/bricks/characterize.md +17 -0
- package/src/bricks/ci-fix.md +18 -0
- package/src/bricks/ci-triage.md +20 -0
- package/src/bricks/compat-check.md +16 -0
- package/src/bricks/deps-audit.md +14 -0
- package/src/bricks/docs-update.md +15 -0
- package/src/bricks/done-check.md +16 -0
- package/src/bricks/fitness-define.md +22 -0
- package/src/bricks/flake-check.md +17 -0
- package/src/bricks/flake-repro.md +18 -0
- package/src/bricks/identify.md +23 -0
- package/src/bricks/lint.md +14 -0
- package/src/bricks/migration-plan.md +21 -0
- package/src/bricks/mutation-check.md +18 -0
- package/src/bricks/plan.md +17 -0
- package/src/bricks/red-test.md +16 -0
- package/src/bricks/reproduce.md +16 -0
- package/src/bricks/review.md +19 -0
- package/src/bricks/security-scan.md +17 -0
- package/src/bricks/spec.md +18 -0
- package/src/bricks/test.md +14 -0
- package/src/bricks/upgrade-scout.md +16 -0
- package/src/doctrines/process.md +11 -8
- package/src/loops/api-endpoint.json +31 -0
- package/src/loops/architecture-decision.json +22 -0
- package/src/loops/bugfix.json +23 -0
- package/src/loops/ci-repair.json +19 -0
- package/src/loops/deps-upgrade.json +29 -0
- package/src/loops/enforce-architecture-rule.json +28 -0
- package/src/loops/feature.json +24 -0
- package/src/loops/fix-flaky-tests.json +30 -0
- package/src/loops/legacy-refactor.json +26 -0
- package/src/loops/legacy-tests.json +29 -0
- package/src/loops/refactor.json +24 -0
- package/src/loops/remove-dead-code.json +30 -0
- package/src/loops/spec-first-feature.json +29 -0
- package/src/loops/tdd.json +25 -0
- package/src/loops/update-contract.json +33 -0
- package/src/skills/brick-forge.md +48 -0
- package/src/skills/bruno-collection-generator.md +3 -3
- package/src/skills/bruno-test-writer.md +3 -3
- package/src/skills/ci-fix.md +3 -3
- package/src/skills/consolidate-memory.md +4 -4
- package/src/skills/council.md +1 -1
- package/src/skills/fresh-eyes.md +1 -1
- package/src/skills/geneseed.md +1 -1
- package/src/skills/git-archaeology.md +1 -1
- package/src/skills/git-rescue.md +4 -3
- package/src/skills/handoff.md +1 -1
- package/src/skills/herdr.md +1 -1
- package/src/skills/learn-mode.md +2 -2
- package/src/skills/loop.md +111 -0
- package/src/skills/opencode-theme.md +3 -3
- package/src/skills/ponytail.md +1 -1
- package/src/skills/profile.md +3 -4
- package/src/skills/prose.md +2 -3
- package/src/skills/quiz.md +1 -1
- package/src/skills/react-view-transitions/SKILL.md +1 -1
- package/src/skills/react-view-transitions/VENDOR.md +8 -3
- package/src/skills/research.md +3 -3
- package/src/skills/review-response.md +2 -2
- package/src/skills/rule.md +3 -3
- package/src/skills/security-audit.md +4 -4
- package/src/skills/teach.md +1 -1
- package/src/skills/wiki.md +3 -3
- package/src/skills/workflow.md +4 -4
- package/src/skills/worktree.md +2 -2
- package/themes/_TEMPLATE.json +2 -0
- package/themes/biker.json +2 -0
- package/themes/commentator.json +2 -0
- package/themes/cyberpunk.json +2 -0
- package/themes/gamer.json +2 -0
- package/themes/imperial.json +2 -0
- package/themes/joker.json +2 -0
- package/themes/marvin.json +2 -0
- package/themes/mean.json +2 -0
- package/themes/military.json +2 -0
- package/themes/neutral.json +2 -0
- package/themes/pirate.json +2 -0
- package/themes/sports.json +2 -0
- package/themes/verstappen.json +2 -0
- package/themes/wizard.json +2 -0
- package/web/dist/assets/Activity-Ca2Aruu7.js +1 -0
- package/web/dist/assets/ActivityDetail-Bo7v4Z_r.js +1 -0
- package/web/dist/assets/Dashboard-BuMq46aT.js +2 -0
- package/web/dist/assets/Docs-BzccCllG.js +5 -0
- package/web/dist/assets/FilterInput-CBcJBQPI.js +1 -0
- package/web/dist/assets/GreenhouseView-BCIKcnGG.js +2 -0
- package/web/dist/assets/Installs-DMFJvq7s.js +2 -0
- package/web/dist/assets/JournalView-o1rmd1_1.js +1 -0
- package/web/dist/assets/Laws-BANxq_I_.js +3 -0
- package/web/dist/assets/Library-DW4x0fbh.js +1 -0
- package/web/dist/assets/Loops-DEBibbBP.js +2 -0
- package/web/dist/assets/{Markdown-qjM2_ofS.js → Markdown-CdTX0kLe.js} +14 -14
- package/web/dist/assets/OperatorHudView-CLD0fYaA.js +2 -0
- package/web/dist/assets/Personal-6uMJvyBG.js +1 -0
- package/web/dist/assets/Tabs-BubnyZhF.js +1 -0
- package/web/dist/assets/activity-CObqyxkL.js +1 -0
- package/web/dist/assets/{api-Dc8f3ca-.js → api-D_ixdyEY.js} +1 -1
- package/web/dist/assets/drawable-C_x5xncq.js +1 -0
- package/web/dist/assets/index-CMPgezFm.js +2 -0
- package/web/dist/assets/index-DMsT4Aa6.css +1 -0
- package/web/dist/assets/{react-B9Z_tXRV.js → react-Bbzhmq4v.js} +1 -1
- package/web/dist/assets/rolldown-runtime-hePW80VL.js +1 -0
- package/web/dist/assets/sections-Dg23Hel5.js +1 -0
- package/web/dist/assets/usePoll-BOqeunzF.js +1 -0
- package/web/dist/index.html +6 -5
- package/web/dist/assets/Activity-C52LEb-2.js +0 -1
- package/web/dist/assets/ActivityDetail-CjNLRvXg.js +0 -1
- package/web/dist/assets/Dashboard-B6_sSCHi.js +0 -2
- package/web/dist/assets/Docs-Dj7BmCSA.js +0 -5
- package/web/dist/assets/FilterInput-CVyqmYWM.js +0 -1
- package/web/dist/assets/GreenhouseView-BALwjQsA.js +0 -2
- package/web/dist/assets/Installs-BbffDQm3.js +0 -2
- package/web/dist/assets/JournalView-JpAQ8Hqe.js +0 -1
- package/web/dist/assets/Laws-DK7eCWSm.js +0 -3
- package/web/dist/assets/Library-CoamHelE.js +0 -1
- package/web/dist/assets/OperatorHudView-DVx1HlBx.js +0 -2
- package/web/dist/assets/Personal-eaXb1zcj.js +0 -1
- package/web/dist/assets/Tabs-BmGtt8E2.js +0 -1
- package/web/dist/assets/drawable-Du_PZrQj.js +0 -1
- package/web/dist/assets/index-Bz_KMRDH.css +0 -1
- package/web/dist/assets/index-CNFH7o55.js +0 -2
- package/web/dist/assets/sections-GalMS0Zv.js +0 -1
- package/web/dist/assets/usePoll-O0sJEw1Y.js +0 -1
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* js/hosts/gitref.mjs — "what branch, and is a loop running here", read straight off the
|
|
3
|
+
* filesystem. Never spawns git.
|
|
4
|
+
*
|
|
5
|
+
* ONE OWNER, fs/path ONLY, NOTHING ELSE. Before this module, `currentBranch` and the
|
|
6
|
+
* launched-loop evidence reader were duplicated in `js/hosts/hooks.mjs` (the hook's
|
|
7
|
+
* `loopExempt`) and `js/loop/cli.mjs` (the CLI's commit-message writer) — two copies of the
|
|
8
|
+
* same worktree-following walk that could silently drift apart. Both now import this one.
|
|
9
|
+
* `js/hosts/hooks.mjs` is NOT a safe shared home for it: it pulls in `node:child_process`
|
|
10
|
+
* (`spawnSync`, for the destructive-act guard's own concerns) and the hook-dispatch
|
|
11
|
+
* machinery (`jsonDumpsCompact`, the ledger), none of which `js/loop/cli.mjs` — under a hard
|
|
12
|
+
* `child_process` ban like every other closure that feeds `bin/geneseed-cli.mjs` — may ever
|
|
13
|
+
* import. A module that is fs/path only is importable from anywhere.
|
|
14
|
+
*
|
|
15
|
+
* The OpenCode guard plugin (`adapters/opencode/plugins/geneseed-guard.js`) keeps its own
|
|
16
|
+
* standalone copy — it cannot import from `js/` at all — kept in step by hand, commented as
|
|
17
|
+
* this module's twin.
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, statSync, lstatSync, readFileSync, openSync, readSync, closeSync } from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The directory holding `.git` for `cwd` — walking up from `cwd`. A worktree's `.git` is a
|
|
24
|
+
* FILE naming its gitdir elsewhere; LOOP.md always lives beside that FILE, not beside the
|
|
25
|
+
* gitdir it points to, so this is the directory the launched-loop evidence reader looks in.
|
|
26
|
+
* null on anything unexpected.
|
|
27
|
+
*/
|
|
28
|
+
export function gitRootOf(cwd) {
|
|
29
|
+
try {
|
|
30
|
+
let dir = path.resolve(cwd);
|
|
31
|
+
for (;;) {
|
|
32
|
+
if (existsSync(path.join(dir, '.git'))) return dir;
|
|
33
|
+
const up = path.dirname(dir);
|
|
34
|
+
if (up === dir) return null;
|
|
35
|
+
dir = up;
|
|
36
|
+
}
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The `.git` DIRECTORY for `root` — itself, or the gitdir a worktree's `.git` FILE names.
|
|
44
|
+
* Throws if `.git` is a file that does not name one: a malformed worktree link is a louder
|
|
45
|
+
* failure than a silent "no gitdir", and every caller here wants to know before it acts on it.
|
|
46
|
+
*/
|
|
47
|
+
export function gitDirOf(root) {
|
|
48
|
+
const dotgit = path.join(root, '.git');
|
|
49
|
+
if (statSync(dotgit).isFile()) {
|
|
50
|
+
const m = /^gitdir:\s*(.+?)\s*$/m.exec(readFileSync(dotgit, 'utf8'));
|
|
51
|
+
if (!m) throw new Error(`${dotgit} does not name a gitdir`);
|
|
52
|
+
return path.resolve(root, m[1]);
|
|
53
|
+
}
|
|
54
|
+
return dotgit;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The branch checked out in `cwd`, read from .git/HEAD without spawning git. null on
|
|
59
|
+
* anything unexpected (detached HEAD, no repo, a malformed worktree link, unreadable) — the
|
|
60
|
+
* caller then falls back to asking, never to allowing.
|
|
61
|
+
*/
|
|
62
|
+
export function currentBranch(cwd) {
|
|
63
|
+
try {
|
|
64
|
+
const root = gitRootOf(cwd);
|
|
65
|
+
if (!root) return null;
|
|
66
|
+
const ref = /^ref:\s*refs\/heads\/(.+?)\s*$/m.exec(readFileSync(path.join(gitDirOf(root), 'HEAD'), 'utf8'));
|
|
67
|
+
return ref ? ref[1] : null;
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const LOOP_STATE_MARKER = '<!-- loop-state:begin -->';
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Whether `<root>/LOOP.md` carries the engine's state marker — `geneseed loop init`'s mark
|
|
77
|
+
* that a loop was actually LAUNCHED here, not merely that the branch is named `loop/...`,
|
|
78
|
+
* which anyone can type. `lstatSync` (never `statSync`, which follows a link) must find an
|
|
79
|
+
* ordinary file: a FIFO would block the read, and a symlink could point anywhere outside the
|
|
80
|
+
* repo — either makes "LOOP.md says so" a claim this reader cannot stand behind. Read at
|
|
81
|
+
* most the first 64 KB: the marker is not on the first line (it follows the title and the
|
|
82
|
+
* requirement), so it must appear within those 64 KB — a huge LOOP.md (notes, history) must
|
|
83
|
+
* never make this check slow.
|
|
84
|
+
*/
|
|
85
|
+
export function loopLaunched(root) {
|
|
86
|
+
let fd;
|
|
87
|
+
try {
|
|
88
|
+
const p = path.join(root, 'LOOP.md');
|
|
89
|
+
if (!lstatSync(p).isFile()) return false;
|
|
90
|
+
fd = openSync(p, 'r');
|
|
91
|
+
const buf = Buffer.alloc(65536);
|
|
92
|
+
const n = readSync(fd, buf, 0, buf.length, 0);
|
|
93
|
+
return buf.toString('utf8', 0, n).includes(LOOP_STATE_MARKER);
|
|
94
|
+
} catch {
|
|
95
|
+
return false;
|
|
96
|
+
} finally {
|
|
97
|
+
if (fd !== undefined) { try { closeSync(fd); } catch { /* already gone */ } }
|
|
98
|
+
}
|
|
99
|
+
}
|
package/js/hosts/hooks.mjs
CHANGED
|
@@ -56,6 +56,7 @@ import { NO_WINDOW } from '../lib/proc.mjs';
|
|
|
56
56
|
import {
|
|
57
57
|
GATE_LEDGER, resolveMemoryDir, resolvePath, sovereignBypass,
|
|
58
58
|
} from './hosts.mjs';
|
|
59
|
+
import { currentBranch, gitRootOf, loopLaunched } from './gitref.mjs';
|
|
59
60
|
|
|
60
61
|
const ROOT = path.resolve(import.meta.dirname, '../..');
|
|
61
62
|
|
|
@@ -599,6 +600,88 @@ const GIT_GATE_RE = /\bgit\b[^\n]*\b(?:commit|push)\b/;
|
|
|
599
600
|
const DESTRUCTIVE_GIT_RE =
|
|
600
601
|
/\bgit\b[^\n]*\b(?:reset\s+--hard|clean\s+-[a-zA-Z]*f|branch\s+-D|checkout\s+--\s|push\s+[^\n]*--force)/;
|
|
601
602
|
|
|
603
|
+
// `loopExempt` — a WHITELIST, not the blacklist this replaced. A false ask costs one prompt; a
|
|
604
|
+
// false allow publishes. So the exemption holds only when the ENTIRE command is built from
|
|
605
|
+
// forms read in full below, on a branch a loop was actually launched on (LOOP.md's state
|
|
606
|
+
// marker, left by `geneseed loop init` — not just a name starting with `loop/`, which anyone
|
|
607
|
+
// can type). `currentBranch`/`gitRootOf`/`loopLaunched` live in `./gitref.mjs` — the one
|
|
608
|
+
// fs-only reader this file shares with `js/loop/cli.mjs`'s commit-message writer.
|
|
609
|
+
|
|
610
|
+
// No newline, backtick, or `$(` substitution anywhere — these can smuggle a second command (or
|
|
611
|
+
// a shared-branch target) past every check below, so the command is refused before it is even
|
|
612
|
+
// split. `<`/`>` are refused too: a redirect can both hide a second effect and, as the review
|
|
613
|
+
// found, trail off the end of an otherwise-whitelisted push (`HEAD:main>/dev/null`). A single
|
|
614
|
+
// `&` — not doubled into `&&` — is refused too: a shell runs `a & b` as TWO commands just like
|
|
615
|
+
// `a && b`, and the review found it unparsed by the splitter below, which only recognised the
|
|
616
|
+
// doubled form. `(?<!&)&(?!&)` matches a lone `&` without also matching either half of `&&`.
|
|
617
|
+
const UNSAFE_CHARS_RE = /[\n`<>]|\$\(|(?<!&)&(?!&)/;
|
|
618
|
+
|
|
619
|
+
// The only three git shapes a loop's own automation ever needs: adding unquoted paths,
|
|
620
|
+
// committing via `-F`/`--file` (so the message text, which may contain anything, never has to
|
|
621
|
+
// be quoted on this command line at all — see `js/loop/cli.mjs`'s LOOP_COMMIT_MSG), and
|
|
622
|
+
// read-only inspection. The token class `[\w./:@^~=+,-]` excludes quotes, braces, `!`, `*`,
|
|
623
|
+
// `$` and `&` — a brace or glob expansion (`{a,b}`, `*`) is exactly how a shell can turn one
|
|
624
|
+
// whitelisted-looking token into several unknown ones, so no pattern's path/arg tokens may
|
|
625
|
+
// contain them. The SAME class is the `-F`/`--file` path, not the looser `[^\s'"]+` this once
|
|
626
|
+
// was: with that loose class, `git commit -F {m,--amend}` brace-expands to `git commit -F m
|
|
627
|
+
// --amend` and `git commit -F m*` glob-expands to whatever matches — either turns a commit of
|
|
628
|
+
// the loop's own message file into an amend of the last one. `ARG_RE` already accepts a drive
|
|
629
|
+
// letter (`C:/…`) via its `:`, so tightening it here costs nothing the loop's own writer uses.
|
|
630
|
+
const ARG_RE = '[\\w./:@^~=+,-]+';
|
|
631
|
+
const SEG_ADD_RE = new RegExp(`^git\\s+add(\\s+${ARG_RE})*$`);
|
|
632
|
+
const SEG_COMMIT_RE = new RegExp(`^git\\s+commit(\\s+-q)?\\s+(-F\\s+|--file[=\\s])${ARG_RE}(\\s+-q)?$`);
|
|
633
|
+
const SEG_READONLY_RE = new RegExp(`^git\\s+(status|diff|log|rev-parse|show)(\\s+${ARG_RE})*$`);
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* `git push`, exactly: `[-u|--set-upstream] <remote> HEAD:<branch>` or
|
|
637
|
+
* `HEAD:refs/heads/<branch>` — the ONLY exempt form. Bare `git push`, a remote alone, a plain
|
|
638
|
+
* branch name, or bare `HEAD` are no longer enough: every one of those is redirectable by
|
|
639
|
+
* `push.default`/`remote.*.push`/an upstream config this read cannot see, which is exactly how
|
|
640
|
+
* the review's re-review turned "looks like it only pushes `loop/x`" into "pushes wherever the
|
|
641
|
+
* repo's config says". An EXPLICIT `HEAD:<ref>` refspec is immune to both — git ignores
|
|
642
|
+
* `push.default` once a refspec is given, and a mirror/`+`/`:`-prefixed form refuses to
|
|
643
|
+
* coexist with one. The remote's first character must be alphanumeric so a flag (`-f`,
|
|
644
|
+
* `--all`, `--delete`, …) can never be read as a remote name.
|
|
645
|
+
*/
|
|
646
|
+
function pushSegmentOk(seg, branch) {
|
|
647
|
+
const m = /^git\s+push(?:\s+(.*))?$/.exec(seg);
|
|
648
|
+
if (!m) return false;
|
|
649
|
+
const rest = (m[1] || '').trim();
|
|
650
|
+
if (!rest) return false;
|
|
651
|
+
const tokens = rest.split(/\s+/);
|
|
652
|
+
let i = 0;
|
|
653
|
+
if (tokens[i] === '-u' || tokens[i] === '--set-upstream') i += 1;
|
|
654
|
+
const remote = tokens[i];
|
|
655
|
+
if (remote === undefined || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(remote)) return false;
|
|
656
|
+
i += 1;
|
|
657
|
+
const ref = tokens[i];
|
|
658
|
+
i += 1;
|
|
659
|
+
if (ref === undefined || i !== tokens.length) return false;
|
|
660
|
+
return ref === `HEAD:${branch}` || ref === `HEAD:refs/heads/${branch}`;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Consent Before Push's loop/* exemption: true only when EVERY clause holds —
|
|
665
|
+
* (1) the command carries none of the characters that could smuggle a second effect past this
|
|
666
|
+
* read, (2)-(3) every `&&`/`;`/`||`/`|`-separated segment is one of the three whitelisted git
|
|
667
|
+
* shapes above, with every push segment also passing `pushSegmentOk`, and (4) the branch
|
|
668
|
+
* checked out in `cwd` starts with `loop/` AND its git root carries a launched LOOP.md. Splitting
|
|
669
|
+
* on the separators without respecting quotes is deliberate, not an oversight: (1) and the
|
|
670
|
+
* per-segment whitelist already refuse any segment a quoted separator could produce, because a
|
|
671
|
+
* quote character fails every one of the three segment patterns.
|
|
672
|
+
*/
|
|
673
|
+
export function loopExempt(command, cwd) {
|
|
674
|
+
if (typeof command !== 'string' || UNSAFE_CHARS_RE.test(command)) return false;
|
|
675
|
+
const branch = currentBranch(cwd);
|
|
676
|
+
if (!branch || !branch.startsWith('loop/')) return false;
|
|
677
|
+
const root = gitRootOf(cwd);
|
|
678
|
+
if (!root || !loopLaunched(root)) return false;
|
|
679
|
+
const segments = command.split(/&&|\|\||[;|]/).map((s) => s.trim()).filter(Boolean);
|
|
680
|
+
if (!segments.length) return false;
|
|
681
|
+
return segments.every((seg) => SEG_ADD_RE.test(seg) || SEG_COMMIT_RE.test(seg)
|
|
682
|
+
|| SEG_READONLY_RE.test(seg) || pushSegmentOk(seg, branch));
|
|
683
|
+
}
|
|
684
|
+
|
|
602
685
|
/** The `permissionDecision: "ask"` document, in Python's compact `json.dumps` spelling. */
|
|
603
686
|
function askDecision(reason) {
|
|
604
687
|
return `${jsonDumpsCompact({
|
|
@@ -739,8 +822,12 @@ function gitDecide(args, payload) {
|
|
|
739
822
|
+ 'discarding git act needs confirmation bound to this specific command');
|
|
740
823
|
}
|
|
741
824
|
// `--no-consent`: the process pack (or process 5 alone) is off, so the commit/push ask has
|
|
742
|
-
// no rule behind it — but Law IV above is universal and has already run.
|
|
825
|
+
// no rule behind it — but Law IV above is universal and has already run. Checked before
|
|
826
|
+
// `loopExempt` so a non-git command, or one with consent off, never pays for the whitelist.
|
|
743
827
|
if (args.noConsent || !GIT_GATE_RE.test(command)) return 0;
|
|
828
|
+
// Consent Before Push: a loop launched on its own `loop/*` branch is a named batch for a
|
|
829
|
+
// fixed set of commit/push forms on that branch only — never a merge into anything shared.
|
|
830
|
+
if (loopExempt(command, (payload && payload.cwd) || process.cwd())) return 0;
|
|
744
831
|
return ask(args, 'git-gate', 'process-5', 'Geneseed (Consent Before Push) \u2014 every git '
|
|
745
832
|
+ 'commit/push needs explicit approval; to see the change first, use the explain-changes skill');
|
|
746
833
|
}
|
package/js/hosts/installs.mjs
CHANGED
|
@@ -230,6 +230,27 @@ function leadOfDir(d, names) {
|
|
|
230
230
|
export const postureOfDir = (d) => leadOfDir(d, discoverNames('postures', 'peer'));
|
|
231
231
|
export const modeOfDir = (d) => leadOfDir(d, discoverNames('modes', 'direct'));
|
|
232
232
|
|
|
233
|
+
/**
|
|
234
|
+
* The loop skill's default trust preset, read back off a deployed install — `null` when no
|
|
235
|
+
* loop skill is deployed there.
|
|
236
|
+
*
|
|
237
|
+
* CARRIED BY THE SKILL, NOT BY A CARRIER: `--trust` is the one build axis with zero always-on
|
|
238
|
+
* footprint, so it is not in AGENT.md to be found. The skill renders a literal
|
|
239
|
+
* `Default trust preset: **<Label>**` line, and this scans the places an emit lands it: the
|
|
240
|
+
* native layer (`skills/loop/SKILL.md`, in the root itself for a global install and under the
|
|
241
|
+
* host's `projectMarker` for a project one) and the plain bundle (`skills/loop.md`).
|
|
242
|
+
*/
|
|
243
|
+
const TRUST_RE = /^Default trust preset: \*\*(Prudent|Balanced|Aggressive)\*\*/m;
|
|
244
|
+
export function trustOfDir(d) {
|
|
245
|
+
for (const base of ['', ...HOSTS.map((h) => h.projectMarker)]) {
|
|
246
|
+
for (const rel of [['skills', 'loop', 'SKILL.md'], ['skills', 'loop.md']]) {
|
|
247
|
+
const m = TRUST_RE.exec(readMaybe(path.join(d, base, ...rel)) ?? '');
|
|
248
|
+
if (m) return m[1].toLowerCase();
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
|
|
233
254
|
/** The `Active packs:` marker line the template emits, in one of the five carriers. */
|
|
234
255
|
const ACTIVE_PACKS_RE = /^Active packs:[ \t]*(.+?)[ \t]*$/m;
|
|
235
256
|
|
package/js/hosts/mcp.mjs
CHANGED
|
@@ -68,7 +68,7 @@ export const MCP_PRESETS = {
|
|
|
68
68
|
+ 'install). Edit GITLAB_PERSONAL_ACCESS_TOKEN (scopes: api, read_repository) '
|
|
69
69
|
+ 'and GITLAB_API_URL before use. For a second instance (a self-hosted server), '
|
|
70
70
|
+ 'copy this block by hand under a new name and point it at the other URL — '
|
|
71
|
-
+ 'docs/guides/mcp.md has the pattern.',
|
|
71
|
+
+ 'docs/guides/mcp-gitlab.md has the pattern.',
|
|
72
72
|
block: {
|
|
73
73
|
type: 'local',
|
|
74
74
|
command: ['npx', '-y', '@zereight/mcp-gitlab'],
|
package/js/hosts/native.mjs
CHANGED
|
@@ -149,12 +149,50 @@ function triggerOf(text) {
|
|
|
149
149
|
* 900-char cap the catalogue weighed 22k chars (~5.6k tokens) across 51 skills, three times
|
|
150
150
|
* the figure the footprint page quoted. The first sentence says WHEN; the later sentences
|
|
151
151
|
* disambiguate, and the body — read as soon as the skill is chosen — still carries them.
|
|
152
|
-
* Cut at the last sentence end inside the cap, never mid-word.
|
|
152
|
+
* Cut at the last sentence end inside the cap, never mid-word. The `…` fallback (no sentence
|
|
153
|
+
* end inside the cap) is a truncated routing signal: `checkBuild` reports it as a doctor
|
|
154
|
+
* failure, so the cure is a shorter first trigger sentence in the source, never a wider cap.
|
|
153
155
|
*/
|
|
154
156
|
const DESCRIPTION_CAP = 320;
|
|
155
157
|
function skillDescription(text) {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
+
return withWhen(descOf(text), triggerOf(text));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The `## When to dispatch` section's FIRST bullet, flattened like `triggerOf` — the agent's
|
|
163
|
+
* WHEN. Claude Code delegates on the subagent `description:` alone, and a purpose line
|
|
164
|
+
* ("Writes, runs, and diagnoses tests.") says what an agent does but never when to hand it
|
|
165
|
+
* work; the dispatch list carried that, in a body the router never reads.
|
|
166
|
+
*/
|
|
167
|
+
function dispatchOf(text) {
|
|
168
|
+
const lines = text.split('\n');
|
|
169
|
+
const start = lines.findIndex((ln) => /^##\s+When to dispatch\b/.test(ln.trim()));
|
|
170
|
+
if (start < 0) return '';
|
|
171
|
+
const para = [];
|
|
172
|
+
for (let i = start + 1; i < lines.length; i += 1) {
|
|
173
|
+
const s = lines[i].trim();
|
|
174
|
+
if (!para.length) {
|
|
175
|
+
if (s.startsWith('- ')) para.push(s.slice(2));
|
|
176
|
+
else if (s.startsWith('#')) break;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (s === '' || s.startsWith('- ') || s.startsWith('#')) break;
|
|
180
|
+
para.push(s);
|
|
181
|
+
}
|
|
182
|
+
return para.join(' ')
|
|
183
|
+
.replace(HTML_COMMENT_RE, '')
|
|
184
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
|
185
|
+
.replace(/\s+/g, ' ')
|
|
186
|
+
.trim();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The emitted `description:` of an agent — purpose + its first dispatch condition. */
|
|
190
|
+
export function agentDescription(text) {
|
|
191
|
+
return withWhen(descOf(text), dispatchOf(text));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Purpose + ` Use when: ` + the first sentence of `trig`, capped — see `skillDescription`. */
|
|
195
|
+
function withWhen(desc, trig) {
|
|
158
196
|
if (!trig) return desc;
|
|
159
197
|
const first = trig.match(/^.*?[.;](?=\s|$)/);
|
|
160
198
|
const full = `${desc} Use when: ${first ? first[0] : trig}`;
|
|
@@ -302,7 +340,7 @@ const WEBFETCH_MARKER = '<!-- webfetch: allow -->';
|
|
|
302
340
|
* Bash keeps the unmarked line byte-identical to what it was before the webfetch marker existed.
|
|
303
341
|
*/
|
|
304
342
|
function claudeAgentFrontmatter(stem, text, overrides) {
|
|
305
|
-
const fm = [`name: ${stem}`, `description: ${jsonDumps(
|
|
343
|
+
const fm = [`name: ${stem}`, `description: ${jsonDumps(agentDescription(text))}`];
|
|
306
344
|
const ov = agentOverride(overrides, stem);
|
|
307
345
|
if (isTruthy(ov.model)) fm.push(`model: ${formatValue(ov.model)}`);
|
|
308
346
|
if (isReadonly(text)) {
|
|
@@ -316,7 +354,7 @@ function claudeAgentFrontmatter(stem, text, overrides) {
|
|
|
316
354
|
|
|
317
355
|
/** `_build_emit._opencode_agent_frontmatter`. */
|
|
318
356
|
function opencodeAgentFrontmatter(stem, text, overrides, theme = null) {
|
|
319
|
-
const fm = [`description: ${jsonDumps(
|
|
357
|
+
const fm = [`description: ${jsonDumps(agentDescription(text))}`, 'mode: subagent'];
|
|
320
358
|
fm.push(`color: ${agentColor(stem, theme)}`);
|
|
321
359
|
pushOverrideLines(fm, agentOverride(overrides, stem));
|
|
322
360
|
if (isReadonly(text)) {
|
|
@@ -25,6 +25,7 @@ import { NO_WINDOW } from '../lib/proc.mjs';
|
|
|
25
25
|
import {
|
|
26
26
|
folderSkillProblems, registryProblems, secretProblems, vendorPinProblems,
|
|
27
27
|
} from './checks-repo.mjs';
|
|
28
|
+
import { catalogProblems } from '../loop/catalog.mjs';
|
|
28
29
|
import { LAW_CLASS, LAW_CLASSES, SKILL_CLASS } from './inventory.mjs';
|
|
29
30
|
import { NOTE, globSorted, has, isDir, isFile, rglob, srcStems } from './scan.mjs';
|
|
30
31
|
import { spawnSync } from 'node:child_process';
|
|
@@ -836,6 +837,15 @@ function loadLearnPromptHead() {
|
|
|
836
837
|
+ 'When in doubt, output exactly: NOTHING.';
|
|
837
838
|
}
|
|
838
839
|
|
|
840
|
+
/**
|
|
841
|
+
* The shipped loop catalogue parses and every shipped template passes the engine's own graph
|
|
842
|
+
* rules. Global and project bricks are deliberately out of scope: doctor judges what this
|
|
843
|
+
* checkout ships, not what a user wrote in their config.
|
|
844
|
+
*/
|
|
845
|
+
export function loopProblems() {
|
|
846
|
+
return catalogProblems({ projectRoot: null, globalLevel: false });
|
|
847
|
+
}
|
|
848
|
+
|
|
839
849
|
/**
|
|
840
850
|
* `_harness_build._authoring_problems` — author-time gates on the source specs and plugins.
|
|
841
851
|
*
|
|
@@ -81,6 +81,34 @@ export function checkBuild(themeName, out, isVendored = isVendoredPath) {
|
|
|
81
81
|
problems.push(`[${themeName}] unresolved token ${tok} in ${rel}`);
|
|
82
82
|
}
|
|
83
83
|
for (const p of linkProblems(md, text, outAbs, rel)) problems.push(`[${themeName}] ${p}`);
|
|
84
|
+
for (const p of descriptionProblems(text, rel)) problems.push(`[${themeName}] ${p}`);
|
|
85
|
+
}
|
|
86
|
+
return problems;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The `description:` of an emitted native skill or agent, held to Anthropic's skill-authoring
|
|
91
|
+
* rules — it is the ONLY text a host routes on, so a defect here is a skill that silently
|
|
92
|
+
* never fires. Three arms: a `…` ending is `withWhen`'s mid-sentence fallback (the trigger
|
|
93
|
+
* was cut, cure it with a shorter first trigger sentence); an XML-looking tag is rejected
|
|
94
|
+
* outright by the Agent Skills spec; a first/second-person pronoun is the point-of-view drift
|
|
95
|
+
* the spec warns degrades discovery, since the text is injected into the system prompt.
|
|
96
|
+
* The pronoun arm reads only the `Use when:` half, and skips quoted user phrases ("teach me
|
|
97
|
+
* X" is what the user types, not the description's voice): the purpose half is the theme's
|
|
98
|
+
* persona copy ("Make your own OpenCode theme"), authored per theme on purpose.
|
|
99
|
+
* Files without frontmatter (every bundle `.md`, AGENT.md) carry no description and pass.
|
|
100
|
+
*/
|
|
101
|
+
const DESC_LINE_RE = /^---\n(?:.*\n)*?description: (".*")\n(?:.*\n)*?---\n/;
|
|
102
|
+
export function descriptionProblems(text, rel) {
|
|
103
|
+
const m = text.match(DESC_LINE_RE);
|
|
104
|
+
if (!m) return [];
|
|
105
|
+
const desc = parseJson(m[1]);
|
|
106
|
+
const problems = [];
|
|
107
|
+
if (desc.endsWith('…')) problems.push(`description cut mid-sentence in ${rel} — shorten the first trigger sentence`);
|
|
108
|
+
if (/<\/?[A-Za-z][^>]*>/.test(desc)) problems.push(`description carries an XML-like tag in ${rel}`);
|
|
109
|
+
const when = desc.split(' Use when: ')[1] ?? '';
|
|
110
|
+
if (/\b(I|I'm|me|my|you|you're|your)\b/i.test(when.replace(/"[^"]*"|\bI\/O\b/g, ''))) {
|
|
111
|
+
problems.push(`description's "Use when" is not in the third person in ${rel}`);
|
|
84
112
|
}
|
|
85
113
|
return problems;
|
|
86
114
|
}
|
package/js/inspect/diff.mjs
CHANGED
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
} from '../hosts/hosts.mjs';
|
|
47
47
|
import {
|
|
48
48
|
EMIT_HOST_SCOPE, defaultTheme, doctrinesForBuild, footprintOfDir, modeOfDir, postureOfDir,
|
|
49
|
+
trustOfDir,
|
|
49
50
|
readJsonMaybe, readMaybe, themeOfDir,
|
|
50
51
|
} from '../hosts/installs.mjs';
|
|
51
52
|
import { unifiedDiff, splitLines } from '../lib/udiff.mjs';
|
|
@@ -175,6 +176,8 @@ export function diffCollect({ target = null, theme = null, emit = null, footprin
|
|
|
175
176
|
// `null` (no marker) means unknown and renders at all four, which `doctrinesForBuild`
|
|
176
177
|
// makes explicit rather than leaving to `makeCfg`'s parameter default two layers down.
|
|
177
178
|
posture: postureOfDir(dir), mode: modeOfDir(dir), doctrines: doctrinesForBuild(dir),
|
|
179
|
+
// The loop skill's preset, or its `Default trust preset:` line reads as edited.
|
|
180
|
+
trust: trustOfDir(dir),
|
|
178
181
|
}));
|
|
179
182
|
// The one place a render depends on WHERE it is installed: a global Bob emit writes each
|
|
180
183
|
// folder skill's absolute directory into its SKILL.md (see `writeNativeLayer`), so the
|
package/js/inspect/doctor.mjs
CHANGED
|
@@ -36,7 +36,7 @@ import { resolvePath } from '../hosts/hosts.mjs';
|
|
|
36
36
|
import { installedDefaults, themeFiles } from '../hosts/installs.mjs';
|
|
37
37
|
import { validateIsVendored } from '../hosts/native.mjs';
|
|
38
38
|
import { printOut } from '../lib/fs.mjs';
|
|
39
|
-
import { authoringProblems } from './checks-authoring.mjs';
|
|
39
|
+
import { authoringProblems, loopProblems } from './checks-authoring.mjs';
|
|
40
40
|
import {
|
|
41
41
|
checkBuild, colorThemeProblems, renderedProblems, themeParityProblems,
|
|
42
42
|
} from './checks-build.mjs';
|
|
@@ -217,6 +217,7 @@ export function doctorCollect({
|
|
|
217
217
|
problems = problems.concat(ran('parity', 'Theme parity', themeParityProblems()));
|
|
218
218
|
problems = problems.concat(ran('colors', 'Colour themes', colorThemeProblems()));
|
|
219
219
|
problems = problems.concat(ran('authoring', 'Authoring gates', authoringProblems()));
|
|
220
|
+
problems = problems.concat(ran('loops', 'Loop catalogue', loopProblems()));
|
|
220
221
|
problems = problems.concat(ran('shim', 'Hook shim', shimProbs));
|
|
221
222
|
problems = problems.concat(ran('map', 'Module map', moduleMapProblems()));
|
|
222
223
|
// P10c's `cli` check is GONE, and the reason is not that it stopped mattering. It hashed
|
package/js/inspect/inventory.mjs
CHANGED
|
@@ -91,6 +91,7 @@ export const LAW_CLASSES = ['security', 'process', 'verify', 'craft', 'context',
|
|
|
91
91
|
export const SKILL_CLASS = {
|
|
92
92
|
brainstorm: 'design', plan: 'design', council: 'design',
|
|
93
93
|
workflow: 'design', 'parallel-agents': 'design', pipeline: 'design',
|
|
94
|
+
loop: 'design', 'brick-forge': 'design',
|
|
94
95
|
'codebase-design': 'design', 'domain-modeling': 'design', wayfinder: 'design',
|
|
95
96
|
tickets: 'design',
|
|
96
97
|
develop: 'build', refactor: 'build', debug: 'build', migrate: 'build',
|
package/js/inspect/status.mjs
CHANGED
|
@@ -372,7 +372,7 @@ export function statusLines(d, color = false) {
|
|
|
372
372
|
}
|
|
373
373
|
const packs = p.doctrines.length ? p.doctrines.join(',') : 'no packs';
|
|
374
374
|
const excl = p.excludeRules?.length ? `excluding ${p.excludeRules.join(',')}` : 'no rules excluded';
|
|
375
|
-
rows.push([`${p.host}:${p.scope}`, [p.state, p.theme, p.footprint, `${p.posture}/${p.mode}`,
|
|
375
|
+
rows.push([`${p.host}:${p.scope}`, [p.state, p.theme, p.footprint, `${p.posture}/${p.mode}/${p.trust}`,
|
|
376
376
|
packs, excl].join(` ${DOT} `) + where]);
|
|
377
377
|
}
|
|
378
378
|
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where bricks and loop templates come from, and which one wins.
|
|
3
|
+
*
|
|
4
|
+
* THREE ORIGINS, ONE LADDER. Shipped (this package's own `src/`), global (the user's, beside
|
|
5
|
+
* `installs.json` under XDG) and project (`<repo>/.geneseed/`, committed, shared with a team).
|
|
6
|
+
* A later origin replaces an earlier one BY NAME — that is the whole point: a team adapts the
|
|
7
|
+
* shipped `reproduce` to its stack without copying every template that uses it. The cost is
|
|
8
|
+
* that a fixed shipped brick stays masked by the override, so the override is reported
|
|
9
|
+
* (`overridden`), never silent.
|
|
10
|
+
*
|
|
11
|
+
* WHY SHIPPED BRICKS ARE READ FROM `src/`, NOT FROM AN INSTALL. The model never reads a brick
|
|
12
|
+
* file: `geneseed loop next` hands it the brick's prompt. So the CLI reads its own source and no
|
|
13
|
+
* host emit has to learn a new directory — four hosts get the catalogue for free and cannot drift.
|
|
14
|
+
*
|
|
15
|
+
* Templates are parsed with JSON.parse on purpose: they are read, never written back into a
|
|
16
|
+
* user's file, so the int/float distinction `parseJson` preserves has nowhere to matter.
|
|
17
|
+
*/
|
|
18
|
+
import { readdirSync } from 'node:fs';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { readText, isFile, isDir } from '../lib/fs.mjs';
|
|
22
|
+
import { frontmatter } from '../hosts/hooks.mjs';
|
|
23
|
+
import { SRC } from '../build/source.mjs';
|
|
24
|
+
import { checkGraph } from './graph.mjs';
|
|
25
|
+
|
|
26
|
+
export function userLoopsDir() {
|
|
27
|
+
const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
|
28
|
+
return path.join(base, 'geneseed');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const KEBAB = /^[a-z0-9][a-z0-9-]*$/;
|
|
32
|
+
|
|
33
|
+
export function parseBrick(text, origin) {
|
|
34
|
+
const [fm, body] = frontmatter(text);
|
|
35
|
+
const get = (k) => (fm.get(k) ?? '').trim();
|
|
36
|
+
const name = get('name');
|
|
37
|
+
const problems = [];
|
|
38
|
+
if (!KEBAB.test(name)) problems.push(`name ${JSON.stringify(name)} must be kebab-case`);
|
|
39
|
+
const description = get('description');
|
|
40
|
+
if (!description) problems.push(`${name}: description is empty`);
|
|
41
|
+
const effect = get('effect');
|
|
42
|
+
if (effect !== 'read' && effect !== 'mutate') {
|
|
43
|
+
problems.push(`${name}: effect must be read or mutate, not ${JSON.stringify(effect)}`);
|
|
44
|
+
}
|
|
45
|
+
const agent = get('agent') || null;
|
|
46
|
+
const skill = get('skill') || null;
|
|
47
|
+
if (!agent === !skill) problems.push(`${name}: exactly one of agent or skill`);
|
|
48
|
+
const outcomes = get('outcomes').split(',').map((s) => s.trim()).filter(Boolean);
|
|
49
|
+
if (!outcomes.length) problems.push(`${name}: outcomes is empty`);
|
|
50
|
+
// `gate: human` holds the transition after this brick until the user answers (state.mjs).
|
|
51
|
+
// Present on the brick only when declared, so an ungated brick's shape is unchanged.
|
|
52
|
+
const gate = get('gate');
|
|
53
|
+
if (gate && gate !== 'human') problems.push(`${name}: gate must be human, not ${JSON.stringify(gate)}`);
|
|
54
|
+
// `gateOn` narrows the gate to the outcomes it lists; any other outcome follows its edge ungated.
|
|
55
|
+
const gateOn = get('gateOn').split(',').map((s) => s.trim()).filter(Boolean);
|
|
56
|
+
if (gateOn.length && gate !== 'human') problems.push(`${name}: gateOn needs gate: human`);
|
|
57
|
+
for (const o of gateOn) if (!outcomes.includes(o)) problems.push(`${name}: gateOn ${o} is not one of its outcomes`);
|
|
58
|
+
return {
|
|
59
|
+
brick: {
|
|
60
|
+
name, description, effect, agent, skill, outcomes, ...(gate ? { gate } : {}),
|
|
61
|
+
...(gateOn.length ? { gateOn } : {}), body: body.trim(), origin,
|
|
62
|
+
},
|
|
63
|
+
problems,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const listFiles = (dir, ext) => (isDir(dir)
|
|
68
|
+
? readdirSync(dir).filter((n) => n.endsWith(ext) && !n.startsWith('_') && n !== 'README.md').sort()
|
|
69
|
+
: []);
|
|
70
|
+
|
|
71
|
+
function availability(brick, srcRoot) {
|
|
72
|
+
if (brick.agent && !isFile(path.join(srcRoot, 'agents', `${brick.agent}.md`))) {
|
|
73
|
+
return { available: false, reason: `agent ${brick.agent} is not shipped` };
|
|
74
|
+
}
|
|
75
|
+
if (brick.skill && !isFile(path.join(srcRoot, 'skills', `${brick.skill}.md`))
|
|
76
|
+
&& !isDir(path.join(srcRoot, 'skills', brick.skill))) {
|
|
77
|
+
return { available: false, reason: `skill ${brick.skill} is not shipped` };
|
|
78
|
+
}
|
|
79
|
+
return { available: true };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function loadCatalog({
|
|
83
|
+
projectRoot = process.cwd(), srcRoot = SRC, globalLevel = true,
|
|
84
|
+
} = {}) {
|
|
85
|
+
const levels = [
|
|
86
|
+
['shipped', path.join(srcRoot, 'bricks'), path.join(srcRoot, 'loops')],
|
|
87
|
+
];
|
|
88
|
+
if (globalLevel) {
|
|
89
|
+
levels.push(['global', path.join(userLoopsDir(), 'bricks'), path.join(userLoopsDir(), 'loops')]);
|
|
90
|
+
}
|
|
91
|
+
if (projectRoot) {
|
|
92
|
+
levels.push(['project', path.join(projectRoot, '.geneseed', 'bricks'), path.join(projectRoot, '.geneseed', 'loops')]);
|
|
93
|
+
}
|
|
94
|
+
const bricks = new Map(); const templates = new Map(); const problems = []; const overridden = [];
|
|
95
|
+
for (const [origin, brickDir, loopDir] of levels) {
|
|
96
|
+
for (const f of listFiles(brickDir, '.md')) {
|
|
97
|
+
const { brick, problems: p } = parseBrick(readText(path.join(brickDir, f)), origin);
|
|
98
|
+
problems.push(...p.map((x) => `bricks/${f} (${origin}): ${x}`));
|
|
99
|
+
if (p.length) continue;
|
|
100
|
+
const prior = bricks.get(brick.name);
|
|
101
|
+
if (prior) overridden.push(`${brick.name} (${origin}, overrides ${prior.origin})`);
|
|
102
|
+
bricks.set(brick.name, { ...brick, ...availability(brick, srcRoot) });
|
|
103
|
+
}
|
|
104
|
+
for (const f of listFiles(loopDir, '.json')) {
|
|
105
|
+
let graph;
|
|
106
|
+
try { graph = JSON.parse(readText(path.join(loopDir, f))); } catch {
|
|
107
|
+
problems.push(`loops/${f}: not JSON`); continue;
|
|
108
|
+
}
|
|
109
|
+
const stem = f.slice(0, -'.json'.length);
|
|
110
|
+
if (graph.name !== stem) {
|
|
111
|
+
problems.push(`loops/${f}: name ${JSON.stringify(graph.name)} does not match the file name`);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
templates.set(stem, { ...graph, origin });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { bricks, templates, problems, overridden: overridden.sort() };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function catalogProblems(opts = {}) {
|
|
121
|
+
const { bricks, templates, problems } = loadCatalog(opts);
|
|
122
|
+
const out = [...problems];
|
|
123
|
+
for (const [name, graph] of [...templates].sort(([a], [b]) => (a < b ? -1 : 1))) {
|
|
124
|
+
out.push(...checkGraph(graph, bricks).map((p) => `loops/${name}: ${p}`));
|
|
125
|
+
}
|
|
126
|
+
return out.sort();
|
|
127
|
+
}
|