geneseed 3.11.0 → 3.12.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 +39 -0
- package/README.md +3 -3
- package/SHIPPED.md +2 -2
- package/adapters/opencode/plugins/geneseed-guard.js +157 -2
- package/bin/geneseed-cli.mjs +4 -0
- package/docs/concepts/foreman-mode.md +1 -1
- package/docs/concepts/loops.md +165 -0
- package/docs/extending.md +5 -0
- package/docs/guides/choose-your-setup.md +14 -0
- package/docs/guides/install.md +1 -1
- package/docs/reference/glossary.md +4 -0
- package/docs/reference/opencode-plugins.md +3 -1
- package/docs/web-ui.md +12 -1
- package/harness.config.json +1 -1
- package/js/README.md +24 -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 +235 -0
- package/js/hosts/gitref.mjs +99 -0
- package/js/hosts/hooks.mjs +88 -1
- package/js/hosts/installs.mjs +21 -0
- package/js/inspect/checks-authoring.mjs +10 -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 +116 -0
- package/js/loop/cli.mjs +151 -0
- package/js/loop/graph.mjs +145 -0
- package/js/loop/score.mjs +143 -0
- package/js/loop/state.mjs +322 -0
- package/js/maintain/migrate.mjs +4 -2
- package/js/web/actions.mjs +9 -3
- package/js/web/api.mjs +38 -2
- package/js/web/daemon.mjs +43 -5
- package/js/web/handler.mjs +1 -0
- package/js/web/jobs.mjs +3 -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/bricks/README.md +32 -0
- package/src/bricks/_template.md +32 -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/ci-fix.md +17 -0
- package/src/bricks/deps-audit.md +14 -0
- package/src/bricks/docs-update.md +15 -0
- package/src/bricks/identify.md +20 -0
- package/src/bricks/lint.md +14 -0
- package/src/bricks/plan.md +17 -0
- package/src/bricks/reproduce.md +16 -0
- package/src/bricks/review.md +17 -0
- package/src/bricks/security-scan.md +17 -0
- package/src/bricks/test.md +14 -0
- package/src/doctrines/process.md +11 -8
- package/src/loops/bugfix.json +22 -0
- package/src/loops/feature.json +21 -0
- package/src/loops/refactor.json +20 -0
- package/src/skills/brick-forge.md +46 -0
- package/src/skills/geneseed.md +1 -1
- package/src/skills/loop.md +99 -0
- 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-C52LEb-2.js → Activity-GaaZWXTY.js} +1 -1
- package/web/dist/assets/{ActivityDetail-CjNLRvXg.js → ActivityDetail-4t7d45Du.js} +1 -1
- package/web/dist/assets/{Dashboard-B6_sSCHi.js → Dashboard-CYn7Tp16.js} +2 -2
- package/web/dist/assets/{Docs-Dj7BmCSA.js → Docs-BeFKLg2T.js} +1 -1
- package/web/dist/assets/{GreenhouseView-BALwjQsA.js → GreenhouseView-C6ibc8IE.js} +1 -1
- package/web/dist/assets/Installs-DRYjYnxs.js +2 -0
- package/web/dist/assets/{JournalView-JpAQ8Hqe.js → JournalView-1HbU1mfg.js} +1 -1
- package/web/dist/assets/{Laws-DK7eCWSm.js → Laws-DFoG8JWB.js} +1 -1
- package/web/dist/assets/{Library-CoamHelE.js → Library-DDjgNwz_.js} +1 -1
- package/web/dist/assets/Loops-D9VEoO0F.js +1 -0
- package/web/dist/assets/{OperatorHudView-DVx1HlBx.js → OperatorHudView-Bs7paWmh.js} +1 -1
- package/web/dist/assets/{Personal-eaXb1zcj.js → Personal-BY5n4gTw.js} +1 -1
- package/web/dist/assets/Tabs-ho-EYD8i.js +1 -0
- package/web/dist/assets/{api-Dc8f3ca-.js → api-CyMRbU2x.js} +1 -1
- package/web/dist/assets/{drawable-Du_PZrQj.js → drawable-CLS9drMb.js} +1 -1
- package/web/dist/assets/{index-Bz_KMRDH.css → index-CX_hXMi5.css} +1 -1
- package/web/dist/assets/index-Cjr7XCA3.js +2 -0
- package/web/dist/index.html +3 -3
- package/web/dist/assets/Installs-BbffDQm3.js +0 -2
- package/web/dist/assets/Tabs-BmGtt8E2.js +0 -1
- package/web/dist/assets/index-CNFH7o55.js +0 -2
package/js/build/driver.mjs
CHANGED
|
@@ -65,6 +65,9 @@ import {
|
|
|
65
65
|
// registry to find every install it must re-emit, and a CLI verb may not reach into a driver
|
|
66
66
|
// for a reader. See js/inspect/registry.mjs.
|
|
67
67
|
import { registryRecord, registryRoots } from '../inspect/registry.mjs';
|
|
68
|
+
// The loop engine's trust presets — the `--trust` choices. `score.mjs` imports nothing, which is
|
|
69
|
+
// what lets it sit inside this driver's total `child_process` ban.
|
|
70
|
+
import { DEFAULT_PRESET, PRESETS } from '../loop/score.mjs';
|
|
68
71
|
|
|
69
72
|
// P2. `--sync-themes` crossed into its own module rather than into this file: it is 90 lines
|
|
70
73
|
// of textual surgery over committed files with a corpus of its own, and it is the half of the
|
|
@@ -111,8 +114,8 @@ export function resolveOut(raw) {
|
|
|
111
114
|
*/
|
|
112
115
|
function configDefaults() {
|
|
113
116
|
const d = {
|
|
114
|
-
theme: 'neutral', posture: 'peer', mode: 'direct',
|
|
115
|
-
excludeRules: [],
|
|
117
|
+
theme: 'neutral', posture: 'peer', mode: 'direct', trust: DEFAULT_PRESET,
|
|
118
|
+
doctrines: [...PACK_ORDER], excludeRules: [],
|
|
116
119
|
};
|
|
117
120
|
if (!existsSync(CONFIG)) return d;
|
|
118
121
|
try {
|
|
@@ -163,7 +166,7 @@ function configDefaults() {
|
|
|
163
166
|
* flag is added to one and not the other, and nothing downstream would say so.
|
|
164
167
|
*/
|
|
165
168
|
const VALUED = {
|
|
166
|
-
'--theme': 'theme', '--posture': 'posture', '--mode': 'mode',
|
|
169
|
+
'--theme': 'theme', '--posture': 'posture', '--mode': 'mode', '--trust': 'trust',
|
|
167
170
|
'--doctrines': 'doctrines', '--exclude-rules': 'excludeRules',
|
|
168
171
|
'--out': 'out', '--target': 'out', '--emit': 'emit',
|
|
169
172
|
'--footprint': 'footprint', '--root': 'root', '--config-dir': 'cfgDir',
|
|
@@ -194,13 +197,13 @@ const FLAG_BY_NAME = Object.fromEntries(
|
|
|
194
197
|
);
|
|
195
198
|
|
|
196
199
|
/**
|
|
197
|
-
* The
|
|
200
|
+
* The five flags `choice` validates, as ONE table for the same anti-drift reason.
|
|
198
201
|
*
|
|
199
|
-
* A function and not a constant because two of the
|
|
202
|
+
* A function and not a constant because two of the five read the checkout: `discoverNames`
|
|
200
203
|
* scans `postures/` and `modes/`, so the answer depends on the source tree this driver is
|
|
201
204
|
* standing in and cannot be frozen at import.
|
|
202
205
|
*
|
|
203
|
-
* `--doctrines` is DELIBERATELY not a
|
|
206
|
+
* `--doctrines` is DELIBERATELY not a sixth row. Every consumer of this table treats an
|
|
204
207
|
* entry as one value drawn from a closed list — `choice` refuses anything else, and
|
|
205
208
|
* `usage`'s metavar prints the list as the whole legal surface. `--doctrines` takes a comma
|
|
206
209
|
* LIST plus the sentinel `none`, so a row here would have refused `craft,rigor` outright and
|
|
@@ -213,6 +216,7 @@ function choicesFor() {
|
|
|
213
216
|
'--footprint': ['lean', 'full'],
|
|
214
217
|
'--posture': discoverNames('postures', 'peer'),
|
|
215
218
|
'--mode': discoverNames('modes', 'direct'),
|
|
219
|
+
'--trust': Object.keys(PRESETS),
|
|
216
220
|
};
|
|
217
221
|
}
|
|
218
222
|
|
|
@@ -340,6 +344,7 @@ function usage() {
|
|
|
340
344
|
function parseArgs(argv, defaults) {
|
|
341
345
|
const args = {
|
|
342
346
|
theme: defaults.theme, posture: defaults.posture, mode: defaults.mode,
|
|
347
|
+
trust: defaults.trust,
|
|
343
348
|
doctrines: defaults.doctrines,
|
|
344
349
|
excludeRules: defaults.excludeRules,
|
|
345
350
|
out: 'Harness', emit: 'files', footprint: 'lean', root: null,
|
|
@@ -1035,7 +1040,7 @@ export function buildInto({ theme, out, footprint = 'lean' }) {
|
|
|
1035
1040
|
}
|
|
1036
1041
|
|
|
1037
1042
|
export function emitGlobalInto(host, {
|
|
1038
|
-
theme, out, cfgDir, footprint, posture = null, mode = null, doctrines = null,
|
|
1043
|
+
theme, out, cfgDir, footprint, posture = null, mode = null, doctrines = null, trust = null,
|
|
1039
1044
|
}) {
|
|
1040
1045
|
// `build.HOSTS.get(host, build.HOSTS["opencode"])` — an unknown host falls back rather than
|
|
1041
1046
|
// raising, because the host comes from a marker file a user can edit.
|
|
@@ -1058,7 +1063,8 @@ export function emitGlobalInto(host, {
|
|
|
1058
1063
|
// at `[]`, so the key is OMITTED instead of passed through — `makeCfg`'s own default is
|
|
1059
1064
|
// the fail-closed answer and an explicit `doctrines: null` would not reach it.
|
|
1060
1065
|
makeCfg({
|
|
1061
|
-
posture: posture || 'peer', mode: mode || 'direct',
|
|
1066
|
+
posture: posture || 'peer', mode: mode || 'direct', trust: trust || DEFAULT_PRESET,
|
|
1067
|
+
...(doctrines ? { doctrines } : {}),
|
|
1062
1068
|
}),
|
|
1063
1069
|
{ theme, footprint, root: null, cfgDir }, out,
|
|
1064
1070
|
),
|
package/js/build/generate.mjs
CHANGED
|
@@ -34,11 +34,12 @@ import path from 'node:path';
|
|
|
34
34
|
|
|
35
35
|
import { main as driverMain } from './driver.mjs';
|
|
36
36
|
import { makeCfg, PACK_ORDER } from './source.mjs';
|
|
37
|
+
import { DEFAULT_PRESET } from '../loop/score.mjs';
|
|
37
38
|
import { opencodeConfigDir, resolvePath } from '../hosts/hosts.mjs';
|
|
38
39
|
import {
|
|
39
40
|
EMIT_HOST_SCOPE, defaultMode, defaultPosture, defaultTheme, doctrinesForBuild,
|
|
40
41
|
excludedRulesOfDir, footprintOfDir, installState, installTargets, modeOfDir, postureOfDir,
|
|
41
|
-
readMaybe, themeOfDir,
|
|
42
|
+
readMaybe, themeOfDir, trustOfDir,
|
|
42
43
|
} from '../hosts/installs.mjs';
|
|
43
44
|
import { colorThemeFiles, colorThemeJson, PALETTE_ROLES } from '../hosts/opencode.mjs';
|
|
44
45
|
import { renderAll } from './render.mjs';
|
|
@@ -93,7 +94,7 @@ function sysExit(msg) {
|
|
|
93
94
|
*/
|
|
94
95
|
const BUILD_FORWARD = [
|
|
95
96
|
['theme', '--theme'], ['emit', '--emit'], ['footprint', '--footprint'],
|
|
96
|
-
['posture', '--posture'], ['mode', '--mode'], ['doctrines', '--doctrines'],
|
|
97
|
+
['posture', '--posture'], ['mode', '--mode'], ['trust', '--trust'], ['doctrines', '--doctrines'],
|
|
97
98
|
['excludeRules', '--exclude-rules'], ['out', '--out'], ['root', '--root'],
|
|
98
99
|
['configDir', '--config-dir'],
|
|
99
100
|
];
|
|
@@ -114,7 +115,7 @@ export function cmdBuild(args) {
|
|
|
114
115
|
* The elision rules are not symmetric and the Python says why: `footprint` is ALWAYS passed,
|
|
115
116
|
* because omitting it when it matched the generator's default silently coupled the answer to
|
|
116
117
|
* whatever that default happened to be — the moment the default moved, picking the old one
|
|
117
|
-
* produced the new one. `posture` and `
|
|
118
|
+
* produced the new one. `posture`, `mode` and `trust` still elide at theirs, and none has moved.
|
|
118
119
|
*
|
|
119
120
|
* ⚠ `doctrines` DOES NOT ELIDE, AND THAT IS THE SAFETY DECISION, NOT A STYLE ONE. It used to
|
|
120
121
|
* elide on the full set, by analogy with posture and mode — but those two compare against a
|
|
@@ -137,7 +138,7 @@ export function cmdBuild(args) {
|
|
|
137
138
|
*/
|
|
138
139
|
export function setupBuildArgs(theme, emit, out = null, root = null, footprint = 'lean',
|
|
139
140
|
posture = 'peer', mode = 'direct', doctrines = null, allPacks = PACK_ORDER,
|
|
140
|
-
excludeRules = null) {
|
|
141
|
+
excludeRules = null, trust = DEFAULT_PRESET) {
|
|
141
142
|
const argv = ['--theme', theme, '--emit', emit];
|
|
142
143
|
if (!emit.endsWith('-global')) {
|
|
143
144
|
if (out) argv.push('--out', out);
|
|
@@ -146,6 +147,7 @@ export function setupBuildArgs(theme, emit, out = null, root = null, footprint =
|
|
|
146
147
|
if (footprint) argv.push('--footprint', footprint);
|
|
147
148
|
if (posture && posture !== 'peer') argv.push('--posture', posture);
|
|
148
149
|
if (mode && mode !== 'direct') argv.push('--mode', mode);
|
|
150
|
+
if (trust && trust !== DEFAULT_PRESET) argv.push('--trust', trust);
|
|
149
151
|
if (Array.isArray(doctrines)) {
|
|
150
152
|
// Emitted in `allPacks` order, not the order handed in: a caller may legitimately pass an
|
|
151
153
|
// unsorted list, and the flag has to be canonical for the same reason the driver
|
|
@@ -199,6 +201,8 @@ export function installProfile(host, scope, root) {
|
|
|
199
201
|
const footprint = footprintOfDir(root);
|
|
200
202
|
const posture = postureOfDir(root) || defaultPosture();
|
|
201
203
|
const mode = modeOfDir(root) || defaultMode();
|
|
204
|
+
// Read off the deployed loop skill; no config fallback — `DEFAULT_PRESET` is the engine's.
|
|
205
|
+
const trust = trustOfDir(root) || DEFAULT_PRESET;
|
|
202
206
|
// A rebuild that defaulted the pack selection would re-emit an install into a constitution
|
|
203
207
|
// its owner did not choose — and in one direction that is not merely surprising: dropping
|
|
204
208
|
// the process pack takes the commit/push consent RULES out of AGENT.md while
|
|
@@ -213,7 +217,7 @@ export function installProfile(host, scope, root) {
|
|
|
213
217
|
const excludeRules = excludedRulesOfDir(root);
|
|
214
218
|
const out = scope === 'global' ? null : root;
|
|
215
219
|
const argv = setupBuildArgs(theme, emit, out, out, footprint, posture, mode, doctrines,
|
|
216
|
-
PACK_ORDER, excludeRules);
|
|
220
|
+
PACK_ORDER, excludeRules, trust);
|
|
217
221
|
// A GLOBAL ROW NAMES ITS OWN DIR. `root` came from the host's config-dir resolver, which
|
|
218
222
|
// honours env overrides (`OPENCODE_CONFIG_DIR`, `CLAUDE_CONFIG_DIR`…); without the flag the
|
|
219
223
|
// same argv run from a shell lacking that variable re-emits into the DEFAULT dir — a second
|
|
@@ -221,7 +225,7 @@ export function installProfile(host, scope, root) {
|
|
|
221
225
|
if (scope === 'global') argv.push('--config-dir', root);
|
|
222
226
|
return {
|
|
223
227
|
host, scope, root, state: installState(root, host, scope),
|
|
224
|
-
emit, theme, footprint, posture, mode, doctrines, excludeRules, argv,
|
|
228
|
+
emit, theme, footprint, posture, mode, trust, doctrines, excludeRules, argv,
|
|
225
229
|
};
|
|
226
230
|
}
|
|
227
231
|
|
package/js/build/render.mjs
CHANGED
|
@@ -35,6 +35,7 @@ import { parseJson } from '../lib/json.mjs';
|
|
|
35
35
|
import { normcase, comparePaths } from '../lib/paths.mjs';
|
|
36
36
|
// `js/build/source.mjs` imports nothing but node builtins, so this direction cannot cycle.
|
|
37
37
|
import { PACK_ORDER, readPackText, resolveRuleIds } from './source.mjs';
|
|
38
|
+
import { DEFAULT_PRESET } from '../loop/score.mjs';
|
|
38
39
|
|
|
39
40
|
/** Document STRUCTURE is theme-INDEPENDENT — mirrors `_build_render.STRUCTURE`. */
|
|
40
41
|
export const STRUCTURE = {
|
|
@@ -391,6 +392,10 @@ export function effectiveTheme(cfg, themeName, { footprint = 'full', lawsPrefix
|
|
|
391
392
|
const theme = { ...loadTheme(cfg, themeName), ...(cfg.structure ?? STRUCTURE) };
|
|
392
393
|
theme.POSTURE_BODY = registerBody(cfg, theme, 'postures', cfg.posture ?? 'peer', 'peer');
|
|
393
394
|
theme.MODE_BODY = registerBody(cfg, theme, 'modes', cfg.mode ?? 'direct', 'direct');
|
|
395
|
+
// The loop skill's default trust preset (`--trust`), raw for `--preset` and capitalised for
|
|
396
|
+
// the `Default trust preset: **<Label>**` line `trustOfDir` reads back off an install.
|
|
397
|
+
theme.TRUST = cfg.trust ?? DEFAULT_PRESET;
|
|
398
|
+
theme.TRUST_LABEL = theme.TRUST[0].toUpperCase() + theme.TRUST.slice(1);
|
|
394
399
|
// The doctrines pair is threaded, not read off `cfg`: `footprint` is a per-RENDER value
|
|
395
400
|
// (`renderAll`'s option, and the same cfg renders both ways in one process), so parking it
|
|
396
401
|
// on cfg would make the terse/full choice sticky across calls. An options object rather
|
package/js/build/source.mjs
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
*/
|
|
27
27
|
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
28
28
|
import path from 'node:path';
|
|
29
|
+
import { DEFAULT_PRESET } from '../loop/score.mjs';
|
|
29
30
|
|
|
30
31
|
/**
|
|
31
32
|
* One doctrine pack file used to be read three times per render — the survives gate and the
|
|
@@ -256,7 +257,8 @@ export function knownRuleIds() {
|
|
|
256
257
|
* `PACK_ORDER` for the whole process.
|
|
257
258
|
*/
|
|
258
259
|
export function makeCfg({
|
|
259
|
-
posture = 'peer', mode = 'direct',
|
|
260
|
+
posture = 'peer', mode = 'direct', trust = DEFAULT_PRESET, doctrines = PACK_ORDER,
|
|
261
|
+
excludeRules = [],
|
|
260
262
|
} = {}) {
|
|
261
263
|
return {
|
|
262
264
|
root: ROOT,
|
|
@@ -268,6 +270,8 @@ export function makeCfg({
|
|
|
268
270
|
workflowSrc: WORKFLOW_SRC,
|
|
269
271
|
posture,
|
|
270
272
|
mode,
|
|
273
|
+
// The loop skill's default preset (`--trust`); the skill is its only reader.
|
|
274
|
+
trust,
|
|
271
275
|
doctrines: Array.isArray(doctrines) ? [...doctrines] : [...PACK_ORDER],
|
|
272
276
|
// ⚠ THE SECOND DOCTRINE AXIS, AND ITS DEFAULT IS THE OPPOSITE WAY ROUND. `doctrines`
|
|
273
277
|
// defaults to EVERYTHING because an unknown selection must bind the most; `excludeRules`
|
package/js/cli-table.json
CHANGED
|
@@ -133,6 +133,21 @@
|
|
|
133
133
|
"type": null,
|
|
134
134
|
"hidden": false
|
|
135
135
|
},
|
|
136
|
+
{
|
|
137
|
+
"names": [
|
|
138
|
+
"--trust"
|
|
139
|
+
],
|
|
140
|
+
"dest": "trust",
|
|
141
|
+
"metavar": null,
|
|
142
|
+
"help": "default loop trust preset: how much a loop may do before it stops to ask (default balanced)",
|
|
143
|
+
"choices": null,
|
|
144
|
+
"default": null,
|
|
145
|
+
"required": false,
|
|
146
|
+
"nargs": null,
|
|
147
|
+
"is_flag": false,
|
|
148
|
+
"type": null,
|
|
149
|
+
"hidden": false
|
|
150
|
+
},
|
|
136
151
|
{
|
|
137
152
|
"names": [
|
|
138
153
|
"--doctrines"
|
|
@@ -623,6 +638,226 @@
|
|
|
623
638
|
"options": [],
|
|
624
639
|
"mutex": []
|
|
625
640
|
},
|
|
641
|
+
{
|
|
642
|
+
"name": "loop",
|
|
643
|
+
"help": "run a loop: check graphs, start one in LOOP.md, and step its state machine (next/score/record/decide)",
|
|
644
|
+
"description": "",
|
|
645
|
+
"positionals": [
|
|
646
|
+
{
|
|
647
|
+
"names": [],
|
|
648
|
+
"dest": "action",
|
|
649
|
+
"metavar": null,
|
|
650
|
+
"help": "",
|
|
651
|
+
"choices": [
|
|
652
|
+
"check",
|
|
653
|
+
"init",
|
|
654
|
+
"next",
|
|
655
|
+
"score",
|
|
656
|
+
"record",
|
|
657
|
+
"decide"
|
|
658
|
+
],
|
|
659
|
+
"default": null,
|
|
660
|
+
"required": true,
|
|
661
|
+
"nargs": null,
|
|
662
|
+
"is_flag": false,
|
|
663
|
+
"type": null,
|
|
664
|
+
"hidden": false
|
|
665
|
+
}
|
|
666
|
+
],
|
|
667
|
+
"options": [
|
|
668
|
+
{
|
|
669
|
+
"names": ["--graph"],
|
|
670
|
+
"dest": "graph",
|
|
671
|
+
"metavar": null,
|
|
672
|
+
"help": "a loop template name or a .json graph file",
|
|
673
|
+
"choices": null,
|
|
674
|
+
"default": null,
|
|
675
|
+
"required": false,
|
|
676
|
+
"nargs": null,
|
|
677
|
+
"is_flag": false,
|
|
678
|
+
"type": null,
|
|
679
|
+
"hidden": false
|
|
680
|
+
},
|
|
681
|
+
{
|
|
682
|
+
"names": ["--brick"],
|
|
683
|
+
"dest": "brick",
|
|
684
|
+
"metavar": null,
|
|
685
|
+
"help": "check one brick file instead of the whole catalogue",
|
|
686
|
+
"choices": null,
|
|
687
|
+
"default": null,
|
|
688
|
+
"required": false,
|
|
689
|
+
"nargs": null,
|
|
690
|
+
"is_flag": false,
|
|
691
|
+
"type": null,
|
|
692
|
+
"hidden": false
|
|
693
|
+
},
|
|
694
|
+
{
|
|
695
|
+
"names": ["--title"],
|
|
696
|
+
"dest": "title",
|
|
697
|
+
"metavar": null,
|
|
698
|
+
"help": "the loop's title, written into LOOP.md",
|
|
699
|
+
"choices": null,
|
|
700
|
+
"default": null,
|
|
701
|
+
"required": false,
|
|
702
|
+
"nargs": null,
|
|
703
|
+
"is_flag": false,
|
|
704
|
+
"type": null,
|
|
705
|
+
"hidden": false
|
|
706
|
+
},
|
|
707
|
+
{
|
|
708
|
+
"names": ["--requirement"],
|
|
709
|
+
"dest": "requirement",
|
|
710
|
+
"metavar": null,
|
|
711
|
+
"help": "the requirement the loop exists to satisfy",
|
|
712
|
+
"choices": null,
|
|
713
|
+
"default": null,
|
|
714
|
+
"required": false,
|
|
715
|
+
"nargs": null,
|
|
716
|
+
"is_flag": false,
|
|
717
|
+
"type": null,
|
|
718
|
+
"hidden": false
|
|
719
|
+
},
|
|
720
|
+
{
|
|
721
|
+
"names": ["--preset"],
|
|
722
|
+
"dest": "preset",
|
|
723
|
+
"metavar": null,
|
|
724
|
+
"help": "the risk preset to run under (default: balanced)",
|
|
725
|
+
"choices": [
|
|
726
|
+
"prudent",
|
|
727
|
+
"balanced",
|
|
728
|
+
"aggressive"
|
|
729
|
+
],
|
|
730
|
+
"default": null,
|
|
731
|
+
"required": false,
|
|
732
|
+
"nargs": null,
|
|
733
|
+
"is_flag": false,
|
|
734
|
+
"type": null,
|
|
735
|
+
"hidden": false
|
|
736
|
+
},
|
|
737
|
+
{
|
|
738
|
+
"names": ["--declared"],
|
|
739
|
+
"dest": "declared",
|
|
740
|
+
"metavar": null,
|
|
741
|
+
"help": "score the card just declared, before it runs",
|
|
742
|
+
"choices": null,
|
|
743
|
+
"default": false,
|
|
744
|
+
"required": false,
|
|
745
|
+
"nargs": "0",
|
|
746
|
+
"is_flag": true,
|
|
747
|
+
"type": null,
|
|
748
|
+
"hidden": false
|
|
749
|
+
},
|
|
750
|
+
{
|
|
751
|
+
"names": ["--diff"],
|
|
752
|
+
"dest": "diff",
|
|
753
|
+
"metavar": null,
|
|
754
|
+
"help": "score the actual diff, read as numstat on stdin",
|
|
755
|
+
"choices": null,
|
|
756
|
+
"default": false,
|
|
757
|
+
"required": false,
|
|
758
|
+
"nargs": "0",
|
|
759
|
+
"is_flag": true,
|
|
760
|
+
"type": null,
|
|
761
|
+
"hidden": false
|
|
762
|
+
},
|
|
763
|
+
{
|
|
764
|
+
"names": ["--actions"],
|
|
765
|
+
"dest": "actions",
|
|
766
|
+
"metavar": null,
|
|
767
|
+
"help": "comma-separated declared action kinds (new-file, logic, api, ...)",
|
|
768
|
+
"choices": null,
|
|
769
|
+
"default": null,
|
|
770
|
+
"required": false,
|
|
771
|
+
"nargs": null,
|
|
772
|
+
"is_flag": false,
|
|
773
|
+
"type": null,
|
|
774
|
+
"hidden": false
|
|
775
|
+
},
|
|
776
|
+
{
|
|
777
|
+
"names": ["--write-set"],
|
|
778
|
+
"dest": "write_set",
|
|
779
|
+
"metavar": null,
|
|
780
|
+
"help": "comma-separated files the declared card expects to touch",
|
|
781
|
+
"choices": null,
|
|
782
|
+
"default": null,
|
|
783
|
+
"required": false,
|
|
784
|
+
"nargs": null,
|
|
785
|
+
"is_flag": false,
|
|
786
|
+
"type": null,
|
|
787
|
+
"hidden": false
|
|
788
|
+
},
|
|
789
|
+
{
|
|
790
|
+
"names": ["--intent"],
|
|
791
|
+
"dest": "intent",
|
|
792
|
+
"metavar": null,
|
|
793
|
+
"help": "a short label for what the declared card is doing",
|
|
794
|
+
"choices": null,
|
|
795
|
+
"default": null,
|
|
796
|
+
"required": false,
|
|
797
|
+
"nargs": null,
|
|
798
|
+
"is_flag": false,
|
|
799
|
+
"type": null,
|
|
800
|
+
"hidden": false
|
|
801
|
+
},
|
|
802
|
+
{
|
|
803
|
+
"names": ["--outcome"],
|
|
804
|
+
"dest": "outcome",
|
|
805
|
+
"metavar": null,
|
|
806
|
+
"help": "the brick's reported outcome, one of its declared outcomes",
|
|
807
|
+
"choices": null,
|
|
808
|
+
"default": null,
|
|
809
|
+
"required": false,
|
|
810
|
+
"nargs": null,
|
|
811
|
+
"is_flag": false,
|
|
812
|
+
"type": null,
|
|
813
|
+
"hidden": false
|
|
814
|
+
},
|
|
815
|
+
{
|
|
816
|
+
"names": ["--card"],
|
|
817
|
+
"dest": "card",
|
|
818
|
+
"metavar": null,
|
|
819
|
+
"help": "a JSON card to record alongside the outcome",
|
|
820
|
+
"choices": null,
|
|
821
|
+
"default": null,
|
|
822
|
+
"required": false,
|
|
823
|
+
"nargs": null,
|
|
824
|
+
"is_flag": false,
|
|
825
|
+
"type": null,
|
|
826
|
+
"hidden": false
|
|
827
|
+
},
|
|
828
|
+
{
|
|
829
|
+
"names": ["--verdict"],
|
|
830
|
+
"dest": "verdict",
|
|
831
|
+
"metavar": null,
|
|
832
|
+
"help": "the user's answer to an awaiting validation",
|
|
833
|
+
"choices": [
|
|
834
|
+
"ok",
|
|
835
|
+
"no",
|
|
836
|
+
"amend"
|
|
837
|
+
],
|
|
838
|
+
"default": null,
|
|
839
|
+
"required": false,
|
|
840
|
+
"nargs": null,
|
|
841
|
+
"is_flag": false,
|
|
842
|
+
"type": null,
|
|
843
|
+
"hidden": false
|
|
844
|
+
},
|
|
845
|
+
{
|
|
846
|
+
"names": ["--note"],
|
|
847
|
+
"dest": "note",
|
|
848
|
+
"metavar": null,
|
|
849
|
+
"help": "a note attached to the verdict, written into LOOP.md",
|
|
850
|
+
"choices": null,
|
|
851
|
+
"default": null,
|
|
852
|
+
"required": false,
|
|
853
|
+
"nargs": null,
|
|
854
|
+
"is_flag": false,
|
|
855
|
+
"type": null,
|
|
856
|
+
"hidden": false
|
|
857
|
+
}
|
|
858
|
+
],
|
|
859
|
+
"mutex": []
|
|
860
|
+
},
|
|
626
861
|
{
|
|
627
862
|
"name": "mcp",
|
|
628
863
|
"help": "list the MCP servers wired into each active install, and the presets available to add",
|
|
@@ -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
|
}
|