geneseed 4.5.0 → 4.6.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 +19 -0
- package/README.md +6 -6
- package/SHIPPED.md +1 -1
- package/adapters/opencode/HOW-OPENCODE-LOADS.md +3 -2
- package/adapters/opencode/plugins/geneseed-context.js +108 -61
- package/adapters/opencode/plugins/geneseed-guard.js +33 -27
- package/adapters/opencode/plugins/geneseed-learn.js +1 -1
- package/docs/guides/install-clone.md +1 -1
- package/docs/guides/install.md +2 -2
- package/docs/reference/troubleshoot-install.md +5 -5
- package/harness.config.json +1 -1
- package/js/README.md +5 -4
- package/js/build/bundle.mjs +11 -2
- package/js/build/driver.mjs +1 -10
- package/js/build/emit-claude.mjs +7 -22
- package/js/build/emit-common.mjs +16 -9
- package/js/build/emit-opencode.mjs +5 -11
- package/js/build/generate.mjs +1 -1
- package/js/hosts/hooks-context.mjs +1 -4
- package/js/hosts/hooks-done.mjs +23 -6
- package/js/hosts/hooks-learn.mjs +5 -5
- package/js/hosts/hooks.mjs +19 -9
- package/js/hosts/hosts.mjs +2 -0
- package/js/hosts/installs.mjs +45 -10
- package/js/hosts/native.mjs +5 -13
- package/js/hosts/opencode.mjs +37 -22
- package/js/hosts/owned.mjs +19 -5
- package/js/hosts/settings.mjs +17 -11
- package/js/hosts/shim.mjs +4 -3
- package/js/hosts/usage.mjs +4 -3
- package/js/inspect/checks-authoring.mjs +9 -7
- package/js/inspect/checks-probe.mjs +14 -17
- package/js/inspect/checks-repo.mjs +13 -54
- package/js/inspect/cost.mjs +5 -6
- package/js/inspect/doctor.mjs +2 -2
- package/js/inspect/scan.mjs +9 -5
- package/js/inspect/scorecard-svg.mjs +3 -3
- package/js/inspect/scorecard-yaml.mjs +407 -0
- package/js/inspect/scorecard.mjs +7 -405
- package/js/inspect/status.mjs +14 -13
- package/js/lib/node-floor.mjs +4 -3
- package/js/maintain/migrate.mjs +2 -3
- package/js/maintain/uninstall.mjs +20 -26
- package/js/web/actions.mjs +2 -2
- package/js/web/api.mjs +36 -24
- package/js/web/gates-api.mjs +5 -8
- package/js/web/usage-api.mjs +14 -17
- package/package.json +2 -2
- package/web/dist/assets/{Activity-BZE6REef.js → Activity-Dj0izJwj.js} +1 -1
- package/web/dist/assets/{ActivityDetail-DCB8Cr2W.js → ActivityDetail-wjq5W02K.js} +1 -1
- package/web/dist/assets/Dashboard-UMbgHbzF.js +2 -0
- package/web/dist/assets/{Docs-D30CiqYy.js → Docs-D3ABE_ds.js} +3 -3
- package/web/dist/assets/{GreenhouseView-Djt-4oO5.js → GreenhouseView-C238LWBD.js} +1 -1
- package/web/dist/assets/{Installs-B3bw5ckh.js → Installs-XSm0a_zQ.js} +2 -2
- package/web/dist/assets/JournalView-BUj4Ff-6.js +1 -0
- package/web/dist/assets/{Laws-B9day2FV.js → Laws-yGBJFKgY.js} +1 -1
- package/web/dist/assets/{Library-BpIrLH-O.js → Library-CkaPVUYU.js} +1 -1
- package/web/dist/assets/{Loops-CxH14kyE.js → Loops-CexwOARr.js} +1 -1
- package/web/dist/assets/{OperatorHudView-otOz6Gha.js → OperatorHudView-D5qTQat0.js} +1 -1
- package/web/dist/assets/{Personal-abL6089y.js → Personal-B_Vx9a7a.js} +1 -1
- package/web/dist/assets/Tabs-YZbGqWPW.js +1 -0
- package/web/dist/assets/drawable-BoQnwVpr.js +1 -0
- package/web/dist/assets/index-BmbWeoTM.js +2 -0
- package/web/dist/assets/{index-CO-9wMJY.css → index-BoG1M8Xy.css} +1 -1
- package/web/dist/index.html +2 -2
- package/web/dist/assets/Dashboard-B4Drq5wp.js +0 -2
- package/web/dist/assets/JournalView-OWhx9YKg.js +0 -1
- package/web/dist/assets/Tabs-DjWN0i7A.js +0 -1
- package/web/dist/assets/drawable-C2ZsBZCv.js +0 -1
- package/web/dist/assets/index-CdpLpPQN.js +0 -2
package/js/build/bundle.mjs
CHANGED
|
@@ -24,6 +24,16 @@ import {
|
|
|
24
24
|
import { writeVersion } from './version.mjs';
|
|
25
25
|
import { existsSync, mkdirSync, renameSync, rmSync } from 'node:fs';
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Seed the memory index, then move a flat one to topics — the pair every emit runs on its
|
|
29
|
+
* memory store, in that order. Here and not in `js/hosts/memory-files.mjs`: `stubs.mjs`
|
|
30
|
+
* imports that module, so the reverse import would be a cycle.
|
|
31
|
+
*/
|
|
32
|
+
export function ensureMemoryStore(memDir) {
|
|
33
|
+
ensureMemoryIndex(memDir);
|
|
34
|
+
migrateMemoryIndex(memDir);
|
|
35
|
+
}
|
|
36
|
+
|
|
27
37
|
// ---------------------------------------------------------------------------
|
|
28
38
|
// Source completeness
|
|
29
39
|
// ---------------------------------------------------------------------------
|
|
@@ -223,8 +233,7 @@ export function build(cfg, themeName, out, { footprint = 'full', nativeCatalog =
|
|
|
223
233
|
ensureRulesStub(out);
|
|
224
234
|
ensureProfileStub(out);
|
|
225
235
|
ensureBundleGitignore(out);
|
|
226
|
-
|
|
227
|
-
migrateMemoryIndex(path.join(out, theme[SRC_DIR_TOKENS.memory] ?? 'memory'));
|
|
236
|
+
ensureMemoryStore(path.join(out, theme[SRC_DIR_TOKENS.memory] ?? 'memory'));
|
|
228
237
|
ensureNotebookIndex(path.join(out, theme[SRC_DIR_TOKENS.notebook] ?? 'notebook'));
|
|
229
238
|
process.stdout.write(`[geneseed] built theme '${themeName}' -> ${out} (${items.length} files)\n`);
|
|
230
239
|
}
|
package/js/build/driver.mjs
CHANGED
|
@@ -51,7 +51,7 @@ import {
|
|
|
51
51
|
openclaudeConfigDir, hostCatalogsNatively,
|
|
52
52
|
} from '../hosts/hosts.mjs';
|
|
53
53
|
import {
|
|
54
|
-
hashesOf, ownedHashes, ownedState, ownedWithin,
|
|
54
|
+
hashesOf, ownedHashes, ownedState, ownedWithin, recordedDenies,
|
|
55
55
|
} from '../hosts/owned.mjs';
|
|
56
56
|
|
|
57
57
|
// P5d moved these out of this file for the reason P5c moved the host resolvers: `harness
|
|
@@ -257,15 +257,6 @@ function readManifest(manifestPath) {
|
|
|
257
257
|
return { existed, doc };
|
|
258
258
|
}
|
|
259
259
|
|
|
260
|
-
/**
|
|
261
|
-
* An OpenCode manifest's `skill_denies` — the `permission.skill` denies Geneseed itself added
|
|
262
|
-
* (see `opencodeLayer`) — or `[]` when absent or malformed, which sweeps nothing.
|
|
263
|
-
*/
|
|
264
|
-
function recordedDenies(doc) {
|
|
265
|
-
const d = doc && doc.skill_denies;
|
|
266
|
-
return Array.isArray(d) ? d.filter((n) => typeof n === 'string') : [];
|
|
267
|
-
}
|
|
268
|
-
|
|
269
260
|
/** The "+ primary agent, N command(s)" tail both OpenCode summary lines share. */
|
|
270
261
|
function extrasTail(stats) {
|
|
271
262
|
const extras = [...(stats.primary ? ['primary agent'] : []),
|
package/js/build/emit-claude.mjs
CHANGED
|
@@ -7,24 +7,21 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { VERSION_MARKER } from '../hosts/hosts.mjs';
|
|
10
|
-
import { migrateMemoryIndex } from '../hosts/memory-files.mjs';
|
|
11
10
|
import { claimer, loadAgentOverrides, writeNativeLayer } from '../hosts/native.mjs';
|
|
12
11
|
import { ensureAgentOverridesStub, writeAliasCommands } from '../hosts/opencode.mjs';
|
|
13
12
|
import {
|
|
14
13
|
managedBlockRemove, managedBlockWrite, mergeClaudeSettings,
|
|
15
14
|
unwireClaudeExcludes, unwireClaudeSettings, wireClaudeExcludes,
|
|
16
15
|
} from '../hosts/settings.mjs';
|
|
17
|
-
import { claimLeaf } from '../hosts/owned.mjs';
|
|
18
|
-
import { writeText } from '../lib/fs.mjs';
|
|
19
16
|
import { get, isDict, isTruthy } from '../lib/json.mjs';
|
|
20
17
|
import { relPosix } from '../lib/text.mjs';
|
|
21
|
-
import { assertSourceComplete, phaseLog } from './bundle.mjs';
|
|
18
|
+
import { assertSourceComplete, phaseLog, ensureMemoryStore } from './bundle.mjs';
|
|
22
19
|
import {
|
|
23
|
-
globalMemory, globalNotebook, shipLeanLaws, stripCapabilityLinks,
|
|
20
|
+
claimAndWrite, globalMemory, globalNotebook, shipLeanLaws, stripCapabilityLinks,
|
|
24
21
|
} from './emit-common.mjs';
|
|
25
22
|
import { renderAll, renderFile } from './render.mjs';
|
|
26
23
|
import {
|
|
27
|
-
BOB_RULES_STUB, ensureContextStub, ensureExcludesStub,
|
|
24
|
+
BOB_RULES_STUB, ensureContextStub, ensureExcludesStub, ensureNotebookIndex,
|
|
28
25
|
ensureProfileStub,
|
|
29
26
|
ensureRulesStub, ensureWikiStub, WIKI_FILE,
|
|
30
27
|
} from './stubs.mjs';
|
|
@@ -100,16 +97,8 @@ export function emitClaudeRender(cfg, job) {
|
|
|
100
97
|
// slim shadow stub whose only job is to have the same filename.
|
|
101
98
|
if (isBob && agentText !== null) {
|
|
102
99
|
const rulesMd = path.join(cfgDir, 'rules', 'geneseed.md');
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
// `rules/` first would plant an empty directory at a linked ancestor's target the moment
|
|
106
|
-
// before the refusal fires.
|
|
107
|
-
if (claimLeaf(cfgDir, rulesMd, 'rules/geneseed.md')) {
|
|
108
|
-
mkdirSync(path.dirname(rulesMd), { recursive: true });
|
|
109
|
-
writeText(rulesMd, scope === 'project' ? BOB_RULES_STUB
|
|
110
|
-
: stripCapabilityLinks(prefixedAgentText('../') || agentText));
|
|
111
|
-
owned.push('rules/geneseed.md');
|
|
112
|
-
}
|
|
100
|
+
claimAndWrite(cfgDir, rulesMd, 'rules/geneseed.md', scope === 'project' ? BOB_RULES_STUB
|
|
101
|
+
: stripCapabilityLinks(prefixedAgentText('../') || agentText), owned);
|
|
113
102
|
}
|
|
114
103
|
|
|
115
104
|
ensureAgentOverridesStub(cfg, cfgDir);
|
|
@@ -146,8 +135,7 @@ export function emitClaudeRender(cfg, job) {
|
|
|
146
135
|
}
|
|
147
136
|
|
|
148
137
|
const memStatus = globalMemory(cfgDir, items, out, cfg.src);
|
|
149
|
-
|
|
150
|
-
migrateMemoryIndex(path.join(cfgDir, 'memory'));
|
|
138
|
+
ensureMemoryStore(path.join(cfgDir, 'memory'));
|
|
151
139
|
const nbStatus = globalNotebook(cfgDir, items, out, cfg.src);
|
|
152
140
|
ensureNotebookIndex(path.join(cfgDir, 'notebook'));
|
|
153
141
|
// context.json too: the root names it as the project's own manifest, and until 2026-10 this
|
|
@@ -172,10 +160,7 @@ export function emitClaudeRender(cfg, job) {
|
|
|
172
160
|
// `!existsSync(gi)` is exactly the case a DANGLING symlink also satisfies — it reads
|
|
173
161
|
// as absent — so `claimLeaf` (which lstats) runs before the write, not instead of
|
|
174
162
|
// this check.
|
|
175
|
-
|
|
176
|
-
writeText(gi, `${giLines.join('\n')}\n`);
|
|
177
|
-
owned.push('.gitignore');
|
|
178
|
-
}
|
|
163
|
+
claimAndWrite(cfgDir, gi, '.gitignore', `${giLines.join('\n')}\n`, owned);
|
|
179
164
|
} else if (oldOwned.includes('.gitignore')) {
|
|
180
165
|
owned.push('.gitignore');
|
|
181
166
|
}
|
package/js/build/emit-common.mjs
CHANGED
|
@@ -63,7 +63,7 @@ export const TMPL_SPEC_RE = /\{\{DIR_(AGENTS|SKILLS)\}\}\/([A-Za-z0-9_-]+)\.md/g
|
|
|
63
63
|
* `Path.rglob("*")` restricted to files, skipping `__pycache__` as Python's callers do.
|
|
64
64
|
*
|
|
65
65
|
* `recursive: true, withFileTypes: true` (Node >= 20.1, `parentPath` >= 21.4 — this repo
|
|
66
|
-
* requires >= 22.
|
|
66
|
+
* requires >= 22.7) replaces a hand-rolled walk; the order it yields is NOT the walk's
|
|
67
67
|
* order, but `sourceFingerprint`, this function's one caller, re-sorts every result by a
|
|
68
68
|
* plain string key before it is ever observed, so no caller depends on it.
|
|
69
69
|
*/
|
|
@@ -184,15 +184,22 @@ export function shipLeanLaws(items, theme, cfgDir, owned, footprint) {
|
|
|
184
184
|
// nothing to detect, not because the gate cannot see it.
|
|
185
185
|
if (text !== null && parts.length && dirs.has(parts[0])) {
|
|
186
186
|
const dest = path.join(cfgDir, ...parts);
|
|
187
|
-
|
|
188
|
-
// does: `safeLeaf` needs no directory created first, and creating one ahead of the
|
|
189
|
-
// refusal would plant it at a linked ancestor's target.
|
|
190
|
-
if (claimLeaf(cfgDir, dest, rel)) {
|
|
191
|
-
mkdirSync(path.dirname(dest), { recursive: true });
|
|
192
|
-
writeText(dest, text);
|
|
193
|
-
owned.push(rel);
|
|
194
|
-
}
|
|
187
|
+
claimAndWrite(cfgDir, dest, rel, text, owned);
|
|
195
188
|
}
|
|
196
189
|
}
|
|
197
190
|
}
|
|
198
191
|
|
|
192
|
+
/**
|
|
193
|
+
* Claim-on-create one leaf: write `text` to `dest` and own `rel` only when `claimLeaf`
|
|
194
|
+
* allows it. `claimLeaf` runs BEFORE `mkdirSync`: `safeLeaf` walks up to the nearest
|
|
195
|
+
* EXISTING ancestor (`resolvePath`'s own contract), so it needs no directory created first —
|
|
196
|
+
* and creating the parent first would plant an empty directory at a linked ancestor's target
|
|
197
|
+
* the moment before the refusal fires. A leaf directly in an existing `root` makes the
|
|
198
|
+
* `mkdirSync` a no-op.
|
|
199
|
+
*/
|
|
200
|
+
export function claimAndWrite(root, dest, rel, text, owned) {
|
|
201
|
+
if (!claimLeaf(root, dest, rel)) return;
|
|
202
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
203
|
+
writeText(dest, text);
|
|
204
|
+
owned.push(rel);
|
|
205
|
+
}
|
|
@@ -8,23 +8,21 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import { VERSION_MARKER } from '../hosts/hosts.mjs';
|
|
11
|
-
import { migrateMemoryIndex } from '../hosts/memory-files.mjs';
|
|
12
11
|
import { claimer, loadAgentOverrides, writeNativeLayer } from '../hosts/native.mjs';
|
|
13
12
|
import {
|
|
14
13
|
copyPlugins, copyWorkflows, ensureAgentOverridesStub, writeColorThemes, writeCommandLayer,
|
|
15
14
|
writeAliasCommands, writePonytailCommand, writePrimaryAgent, writeTheme,
|
|
16
15
|
} from '../hosts/opencode.mjs';
|
|
17
16
|
import { mergeOpencodeJson, opencodeSentinelWrite } from '../hosts/settings.mjs';
|
|
18
|
-
import { claimLeaf } from '../hosts/owned.mjs';
|
|
19
17
|
import { isFile, readText, writeText } from '../lib/fs.mjs';
|
|
20
18
|
import { relPosix } from '../lib/text.mjs';
|
|
21
|
-
import { assertSourceComplete, build, phaseLog } from './bundle.mjs';
|
|
19
|
+
import { assertSourceComplete, build, phaseLog, ensureMemoryStore } from './bundle.mjs';
|
|
22
20
|
import {
|
|
23
|
-
globalMemory, globalNotebook, shipLeanLaws, stripCapabilityLinks,
|
|
21
|
+
claimAndWrite, globalMemory, globalNotebook, shipLeanLaws, stripCapabilityLinks,
|
|
24
22
|
} from './emit-common.mjs';
|
|
25
23
|
import { renderAll } from './render.mjs';
|
|
26
24
|
import {
|
|
27
|
-
ensureExcludesStub,
|
|
25
|
+
ensureExcludesStub, ensureNotebookIndex, ensureProfileStub, ensureRulesStub,
|
|
28
26
|
ensureWikiStub,
|
|
29
27
|
} from './stubs.mjs';
|
|
30
28
|
import { writeVersion } from './version.mjs';
|
|
@@ -216,10 +214,7 @@ export function emitOpencodeGlobalRender(cfg, job) {
|
|
|
216
214
|
// store are siblings, so `memory/` resolves from AGENT.md's own location and stays
|
|
217
215
|
// hermetic — no absolute path a doctor check would flag as an escape.
|
|
218
216
|
const agentDest = path.join(cfgDir, 'AGENT.md');
|
|
219
|
-
|
|
220
|
-
writeText(agentDest, stripCapabilityLinks(agentText));
|
|
221
|
-
owned.push('AGENT.md');
|
|
222
|
-
}
|
|
217
|
+
claimAndWrite(cfgDir, agentDest, 'AGENT.md', stripCapabilityLinks(agentText), owned);
|
|
223
218
|
}
|
|
224
219
|
// Co-owned, so never in `owned` — see `opencodeSentinelWrite`.
|
|
225
220
|
opencodeSentinelWrite(cfgDir);
|
|
@@ -233,8 +228,7 @@ export function emitOpencodeGlobalRender(cfg, job) {
|
|
|
233
228
|
});
|
|
234
229
|
|
|
235
230
|
const memStatus = globalMemory(cfgDir, items, out, cfg.src);
|
|
236
|
-
|
|
237
|
-
migrateMemoryIndex(path.join(cfgDir, 'memory'));
|
|
231
|
+
ensureMemoryStore(path.join(cfgDir, 'memory'));
|
|
238
232
|
const nbStatus = globalNotebook(cfgDir, items, out, cfg.src);
|
|
239
233
|
ensureNotebookIndex(path.join(cfgDir, 'notebook'));
|
|
240
234
|
ensureWikiStub(cfgDir);
|
package/js/build/generate.mjs
CHANGED
|
@@ -223,7 +223,7 @@ export function installProfile(host, scope, root, notify = null) {
|
|
|
223
223
|
// one of them. `null` (no marker) stays `null` so the argv omits the flag, as it always did.
|
|
224
224
|
const excludeRules = excludedRulesOfDir(root, host);
|
|
225
225
|
// Skills left out stay left out: an upgrade that forgot them would put them all back.
|
|
226
|
-
const excludeSkills = excludedSkillsOfDir(root,
|
|
226
|
+
const excludeSkills = excludedSkillsOfDir(root, host, notify);
|
|
227
227
|
const out = scope === 'global' ? null : root;
|
|
228
228
|
const argv = setupBuildArgs(theme, emit, out, out, footprint, posture, mode, doctrines,
|
|
229
229
|
PACK_ORDER, excludeRules, trust, excludeSkills);
|
|
@@ -150,7 +150,7 @@ export { SESSION_FILES, SEED_SHA256 };
|
|
|
150
150
|
const MAP_COUNT_RE = /^- \[([^\]]*)\]\(topics\/[^)]+\.md\) — (\d+) facts?$/;
|
|
151
151
|
// Past this many facts a topic file stops being a quick read, and the curator splits it.
|
|
152
152
|
const CURATE_TOPIC_MAX = 40;
|
|
153
|
-
const CURATE_NUDGE =
|
|
153
|
+
const CURATE_NUDGE = `[memory] A topic holds over ${CURATE_TOPIC_MAX} facts, or facts sit in misc: offer the user `
|
|
154
154
|
+ 'to dispatch the memory-curator agent to merge, re-topic and archive this store.';
|
|
155
155
|
export { CURATE_TOPIC_MAX, CURATE_NUDGE };
|
|
156
156
|
|
|
@@ -193,9 +193,6 @@ export function sessionFiles(hookRoot) {
|
|
|
193
193
|
return found;
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
-
// Moved to `hosts.mjs` so the gates and `learn` share it; re-exported for this verb's callers.
|
|
197
|
-
export { globalHookStandingDown };
|
|
198
|
-
|
|
199
196
|
/** `_disp` — relative to the repo root when it sits under it, else verbatim. */
|
|
200
197
|
function disp(pathStr, root) {
|
|
201
198
|
// A path on another drive has no relative form, so it is printed as given. `path.relative`
|
package/js/hosts/hooks-done.mjs
CHANGED
|
@@ -51,14 +51,30 @@ export function readDoneChecks(root) {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
/**
|
|
54
|
-
* True when `command` runs `line` with no pipe after it in the same segment.
|
|
55
|
-
* `&&`, `||`, `;` or a newline; a single `|` before that end is a pipe.
|
|
56
|
-
*
|
|
57
|
-
*
|
|
54
|
+
* True when `command` runs `line` as a whole command with no pipe after it in the same segment.
|
|
55
|
+
* A segment ends at `&&`, `||`, `;` or a newline; a single `|` before that end is a pipe. The
|
|
56
|
+
* check must open a segment (the start, or right after one of those separators) and be followed
|
|
57
|
+
* by the end, whitespace or a separator, never by more of a word (`npm run lint:fix` does not
|
|
58
|
+
* prove `npm run lint`); a match inside a quoted string (`-m "ran npm test"`) is text, not a run.
|
|
59
|
+
* Whitespace is collapsed on both sides. shortcut: the shell is not parsed — a prefix such as
|
|
60
|
+
* `sudo`/`time`/`env X=1`, a subshell, or a `|` inside a quoted argument after the check all read
|
|
61
|
+
* as "not run". Every approximation here refuses a pass, so the gate errs toward a reminder.
|
|
58
62
|
*/
|
|
59
63
|
export function runsUnpiped(command, line) {
|
|
60
64
|
const cmd = String(command).replace(/[ \t]+/g, ' ');
|
|
61
65
|
for (let i = cmd.indexOf(line); i !== -1; i = cmd.indexOf(line, i + 1)) {
|
|
66
|
+
const next = cmd[i + line.length];
|
|
67
|
+
if (next !== undefined && !/[\s;&|]/.test(next)) continue;
|
|
68
|
+
const head = cmd.slice(0, i);
|
|
69
|
+
if (!/(^|&&|\|\||;|\n) ?$/.test(head)) continue;
|
|
70
|
+
let quote = '', esc = false;
|
|
71
|
+
for (const ch of head) {
|
|
72
|
+
if (esc) esc = false;
|
|
73
|
+
else if (ch === '\\' && quote !== "'") esc = true;
|
|
74
|
+
else if (quote) { if (ch === quote) quote = ''; }
|
|
75
|
+
else if (ch === '"' || ch === "'") quote = ch;
|
|
76
|
+
}
|
|
77
|
+
if (quote) continue;
|
|
62
78
|
const rest = cmd.slice(i + line.length);
|
|
63
79
|
let piped = false;
|
|
64
80
|
for (let j = 0; j < rest.length; j += 1) {
|
|
@@ -126,8 +142,10 @@ export function doneDecide(transcript, checks, root, cwd = root) {
|
|
|
126
142
|
/**
|
|
127
143
|
* The Stop-side wrapper `cmdLearn` calls first. Returns true when it printed a block (the turn
|
|
128
144
|
* goes on, so `learn` must not distil yet); false in every other case, with nothing on stdout.
|
|
145
|
+
* `install` is the root `cmdLearn` already derived from `--memory` (null without one), so the
|
|
146
|
+
* ledger lands in the same root its sovereignty check read.
|
|
129
147
|
*/
|
|
130
|
-
export function doneGate(meta, args) {
|
|
148
|
+
export function doneGate(meta, args, install) {
|
|
131
149
|
try {
|
|
132
150
|
const cwd = typeof meta.cwd === 'string' && meta.cwd ? meta.cwd : process.cwd();
|
|
133
151
|
const root = gitRootOf(cwd);
|
|
@@ -148,7 +166,6 @@ export function doneGate(meta, args) {
|
|
|
148
166
|
}
|
|
149
167
|
const missing = doneDecide(transcript, checks, root, cwd);
|
|
150
168
|
if (!missing.length) return false;
|
|
151
|
-
const install = args.memory ? path.dirname(args.memory) : null;
|
|
152
169
|
if (meta.stop_hook_active === true) {
|
|
153
170
|
recordGate(install, 'done-gate-cap', 'law-3');
|
|
154
171
|
err(`[done-gate] cap reached — turn ends with unproven checks: ${missing.join('; ')}\n`);
|
package/js/hosts/hooks-learn.mjs
CHANGED
|
@@ -293,12 +293,12 @@ export function cmdLearn(args) {
|
|
|
293
293
|
}
|
|
294
294
|
|
|
295
295
|
// The Stop/SubagentStop hook always passes `--memory <cfg>/memory`; inside an excluded
|
|
296
|
-
// folder the global install must not learn.
|
|
297
|
-
|
|
296
|
+
// folder the global install must not learn. The done gate's ledger lives in the same root.
|
|
297
|
+
const install = args.memory ? path.dirname(toPlatformPath(args.memory)) : null;
|
|
298
|
+
if (install && sovereignBypass(install)) return 0;
|
|
298
299
|
// Same install root, and a project install of this host beside it learns on its own Stop: a
|
|
299
300
|
// second LLM call, and the global store learning facts the project owns (Claude B4).
|
|
300
|
-
if (
|
|
301
|
-
hookProjectDir(), args.host, 'learn')) return 0;
|
|
301
|
+
if (install && globalHookStandingDown(install, hookProjectDir(), args.host, 'learn')) return 0;
|
|
302
302
|
|
|
303
303
|
const raw = args.file ? readText(args.file) : readStdin();
|
|
304
304
|
const meta = hookMeta(raw);
|
|
@@ -312,7 +312,7 @@ export function cmdLearn(args) {
|
|
|
312
312
|
|
|
313
313
|
// The done gate (#289) judges the turn BEFORE anything here spawns, and only on Stop. A block
|
|
314
314
|
// means the turn goes on, so there is nothing to distil yet: the next Stop will.
|
|
315
|
-
if (hookEvent === 'Stop' && !args.file && doneGate(meta, args)) return 0;
|
|
315
|
+
if (hookEvent === 'Stop' && !args.file && doneGate(meta, args, install)) return 0;
|
|
316
316
|
|
|
317
317
|
// I2 (Bob): its Stop payload never carries `transcript_path`, so there is never a transcript
|
|
318
318
|
// to distil — and the bare envelope itself is not notes text (that would waste a model call
|
package/js/hosts/hooks.mjs
CHANGED
|
@@ -445,6 +445,8 @@ export function rootScan(command) {
|
|
|
445
445
|
const m = /^-path[=:](.+)$/i.exec(t);
|
|
446
446
|
return m ? m[1] : t;
|
|
447
447
|
});
|
|
448
|
+
// For ls/dir a PowerShell `-Recurse` abbreviation counts from `-re` up: a bare `-r` there is the
|
|
449
|
+
// POSIX reverse flag (`ls -r /`, `dir -r C:\`). gci/Get-ChildItem keep the full abbreviation set.
|
|
448
450
|
if (!always && !argv.some((a) => (grep && GREP_RECURSE_RE.test(a))
|
|
449
451
|
|| (ls && (LS_RECURSE_RE.test(a) || (a.length >= 3 && PS_RECURSE_RE.test(a))))
|
|
450
452
|
|| (ps && PS_RECURSE_RE.test(a)))) continue;
|
|
@@ -452,8 +454,6 @@ export function rootScan(command) {
|
|
|
452
454
|
}
|
|
453
455
|
return null;
|
|
454
456
|
}
|
|
455
|
-
// For ls/dir a PowerShell `-Recurse` abbreviation counts from `-re` up: a bare `-r` there is the
|
|
456
|
-
// POSIX reverse flag (`ls -r /`, `dir -r C:\`). gci/Get-ChildItem keep the full abbreviation set.
|
|
457
457
|
const unquote = (t) => t.replace(/^["']+|["']+$/g, '');
|
|
458
458
|
// A QUOTED token is data — a grep pattern (`grep -rn "/" src`), a path named in a search
|
|
459
459
|
// (`rg "/mnt/c" README.md`) — never a root target; only an unquoted root is one.
|
|
@@ -527,19 +527,19 @@ const SECRET_RE =
|
|
|
527
527
|
/\b(?:AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,}|sk-ant-[A-Za-z0-9_-]{20,}|xox[abprs]-[0-9A-Za-z-]{10,})\b|-----BEGIN [A-Z ]*PRIVATE KEY-----/;
|
|
528
528
|
const DOTENV_RE = /(^|[\\/])\.env(\.[^\\/]*)?$/;
|
|
529
529
|
|
|
530
|
+
export const SENSOR_LIST = '.geneseed/protected-checks.txt';
|
|
531
|
+
// The done gate's list (`./hooks-done.mjs`, #289) protects itself too, with or without a
|
|
532
|
+
// protected-checks list: emptying it would disarm the gate it feeds.
|
|
533
|
+
export const DONE_LIST = '.geneseed/done-checks.txt';
|
|
530
534
|
/**
|
|
531
535
|
* External Gate's boundary half: the checks an agent is judged by must sit outside what it can
|
|
532
536
|
* edit, or a failing check is one edit away from a passing one. A project opts in by listing
|
|
533
537
|
* them, one repo-relative path per line (`#` comments), in `.geneseed/protected-checks.txt` at
|
|
534
538
|
* its git root — found by walking up from the WRITE TARGET, not the cwd, so a session in one repo
|
|
535
539
|
* writing into another reads that other repo's list. The list protects itself. Returns the entry
|
|
536
|
-
* the path falls under, or null.
|
|
540
|
+
* the path falls under, or null. shortcut: a write tool only — a shell `sed -i` or redirect is
|
|
537
541
|
* not seen here; CI running the checks is the boundary that holds against that.
|
|
538
542
|
*/
|
|
539
|
-
export const SENSOR_LIST = '.geneseed/protected-checks.txt';
|
|
540
|
-
// The done gate's list (`./hooks-done.mjs`, #289) protects itself too, with or without a
|
|
541
|
-
// protected-checks list: emptying it would disarm the gate it feeds.
|
|
542
|
-
export const DONE_LIST = '.geneseed/done-checks.txt';
|
|
543
543
|
function protectedCheck(p) {
|
|
544
544
|
try {
|
|
545
545
|
const target = resolvePath(p);
|
|
@@ -563,6 +563,14 @@ function ruleGate(args) {
|
|
|
563
563
|
return ruleDecide(args, readPayload());
|
|
564
564
|
}
|
|
565
565
|
|
|
566
|
+
// The REPLACE halves of a Roo-style `apply_diff` (`<<<<<<< SEARCH … ======= <new> >>>>>>> REPLACE`).
|
|
567
|
+
// A diff without those markers is a shape we do not know, so it is returned whole: fail closed.
|
|
568
|
+
function diffReplaced(diff) {
|
|
569
|
+
if (typeof diff !== 'string') return diff;
|
|
570
|
+
const halves = [...diff.matchAll(/^=======\r?\n([\s\S]*?)^>>>>>>> REPLACE/gm)].map((m) => m[1]);
|
|
571
|
+
return halves.length ? halves.join('\n') : diff;
|
|
572
|
+
}
|
|
573
|
+
|
|
566
574
|
function ruleDecide(args, payload) {
|
|
567
575
|
const ti = (payload && payload.tool_input) || {};
|
|
568
576
|
// NotebookEdit names its target `notebook_path`; the settings matcher routes it here too.
|
|
@@ -575,9 +583,11 @@ function ruleDecide(args, payload) {
|
|
|
575
583
|
// cheap, harmless guard in case that ever changes.
|
|
576
584
|
// `diff` (Bob's `apply_diff`) and `replace`/`search` (`search_and_replace`) are Roo-lineage
|
|
577
585
|
// field names the Bob docs never confirm (bob-verdict.md I1) — accepted when present, never
|
|
578
|
-
// required, same as every field above.
|
|
586
|
+
// required, same as every field above. Only WRITTEN text is scanned: `search` and a diff's
|
|
587
|
+
// SEARCH halves are text leaving the file (like Claude's `old_string`), so a secret there is
|
|
588
|
+
// being removed — blocking it would stop the agent cleaning up a leak.
|
|
579
589
|
const edits = Array.isArray(ti.edits) ? ti.edits.map((e) => e && e.new_string) : [];
|
|
580
|
-
const body = [ti.content, ti.new_string, ti.new_source, ti.diff, ti.replace,
|
|
590
|
+
const body = [ti.content, ti.new_string, ti.new_source, diffReplaced(ti.diff), ti.replace, ...edits]
|
|
581
591
|
.filter((v) => typeof v === 'string').join('\n');
|
|
582
592
|
if (body && !DOTENV_RE.test(p) && SECRET_RE.test(body)) {
|
|
583
593
|
return ask(args, 'rule-gate', 'law-1', `Geneseed (Sealed Secrets) — ${p} would carry a `
|
package/js/hosts/hosts.mjs
CHANGED
|
@@ -55,6 +55,8 @@ export function sovereignBypass(root) {
|
|
|
55
55
|
} catch {
|
|
56
56
|
return false;
|
|
57
57
|
}
|
|
58
|
+
// Nothing to match, so no reason to pay for the cwd and `.git` reads below on every hook call.
|
|
59
|
+
if (!entries.length) return false;
|
|
58
60
|
let heres;
|
|
59
61
|
try {
|
|
60
62
|
heres = [normcase(resolvePath(process.cwd()))];
|
package/js/hosts/installs.mjs
CHANGED
|
@@ -44,7 +44,7 @@ import { registryRoots } from '../inspect/registry.mjs';
|
|
|
44
44
|
import { printErr, readText, isFile, isDir } from '../lib/fs.mjs';
|
|
45
45
|
import { formatRepr, isDict } from '../lib/json.mjs';
|
|
46
46
|
import { comparePaths } from '../lib/paths.mjs';
|
|
47
|
-
import { claudeHookShell } from './shim.mjs';
|
|
47
|
+
import { claudeHookShell, hookShimPath } from './shim.mjs';
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
50
|
* `Path.read_text(encoding="utf-8")`, or null where Python raises OSError.
|
|
@@ -567,10 +567,10 @@ const EXCLUDED_SKILLS_RE = /^Excluded skills:[ \t]*(.+?)[ \t]*$/m;
|
|
|
567
567
|
* name drops out (`resolveSkillNames`), so a skill merge does not quietly re-admit everything
|
|
568
568
|
* else the install left out. Silent unless the caller passes `notify` — status, diff and the
|
|
569
569
|
* console call this on every read; the replays that rebuild an install (`rebuild-all`,
|
|
570
|
-
* `upgrade`, `migrate`) pass it. `host`
|
|
571
|
-
* `
|
|
570
|
+
* `upgrade`, `migrate`) pass it. `host` comes second, the same `(d, host)` order as its
|
|
571
|
+
* siblings `excludedRulesOfDir`/`doctrinesOfDir`, so the marker readers call alike.
|
|
572
572
|
*/
|
|
573
|
-
export function excludedSkillsOfDir(d,
|
|
573
|
+
export function excludedSkillsOfDir(d, host = null, notify = null) {
|
|
574
574
|
return firstCarrier(d, (carrierPath) => {
|
|
575
575
|
const text = readMaybe(carrierPath);
|
|
576
576
|
if (text === null) return undefined;
|
|
@@ -615,9 +615,6 @@ export function emitHostScopeOf(root) {
|
|
|
615
615
|
return EMIT_HOST_SCOPE.get(emit.trim()) ?? null;
|
|
616
616
|
}
|
|
617
617
|
|
|
618
|
-
// `DISABLED_STASH` moved to `hosts.mjs`: the hook stand-down reads it and must not import this.
|
|
619
|
-
export { DISABLED_STASH };
|
|
620
|
-
|
|
621
618
|
/**
|
|
622
619
|
* `_harness_mcp._claude_cfg` — where a Claude-STYLE install keeps its manifest.
|
|
623
620
|
*
|
|
@@ -733,12 +730,12 @@ export function installTargets() {
|
|
|
733
730
|
|
|
734
731
|
/**
|
|
735
732
|
* Each Claude install whose emitted hook FORM no longer matches this machine's hook shell
|
|
736
|
-
* (`claudeHookShell
|
|
733
|
+
* (`claudeHookShell`) — the "Git Bash removed after the emit" gap, which otherwise
|
|
737
734
|
* reports nothing: hooks signal through stdout and Claude treats a parse error as non-blocking.
|
|
738
735
|
*
|
|
739
736
|
* Bash form with Git Bash now gone is a PROBLEM: `context`/`learn` fail open under PowerShell
|
|
740
|
-
* (no `onFailure`, so a launch failure there is still non-blocking)
|
|
741
|
-
* gates fail CLOSED instead (`onFailure: "block"` blocks every Bash/PowerShell/Write/Edit/
|
|
737
|
+
* (no `onFailure`, so a launch failure there is still non-blocking) while the two gates,
|
|
738
|
+
* being gates, fail CLOSED instead (`onFailure: "block"` blocks every Bash/PowerShell/Write/Edit/
|
|
742
739
|
* NotebookEdit call until `rebuild-all` re-emits the PowerShell form) — worse for the user
|
|
743
740
|
* either way, so the message below still reports it as drift needing a rebuild; it does not
|
|
744
741
|
* distinguish the two outcomes. PowerShell form with Git Bash now present is a `[note]`: the hooks work,
|
|
@@ -771,6 +768,44 @@ export function hookShellProblems(targets = installTargets(), platform = process
|
|
|
771
768
|
return out;
|
|
772
769
|
}
|
|
773
770
|
|
|
771
|
+
/**
|
|
772
|
+
* Every active Claude-STYLE install (`CLAUDE_STYLE`: claude, bob, openclaude — the hosts that
|
|
773
|
+
* share this one machine-wide shim) whose LIVE, recorded hooks still name `shimPath` by exact
|
|
774
|
+
* string, keyed by its own config dir.
|
|
775
|
+
*
|
|
776
|
+
* Split out of `shimProblems` (`js/inspect/checks-repo.mjs`) for the case `shimDeadPaths`
|
|
777
|
+
* cannot see: that function reads paths back OUT of the shim body, so it has nothing to read once the shim file is gone
|
|
778
|
+
* outright (deleted, not moved-and-regenerated) — a plain `isFile` check upstream then calls
|
|
779
|
+
* that clean, the same as a source checkout that never emitted. `liveRecordedHooks` is already
|
|
780
|
+
* "the manifest claims it AND the settings file still runs it" (`js/hosts/hosts.mjs`), so a
|
|
781
|
+
* disabled or never-wired install cannot appear here — only installs for which the missing
|
|
782
|
+
* shim is a live, standing failure. It also keeps only handlers whose `command` is a string,
|
|
783
|
+
* so the match below needs no type guard of its own.
|
|
784
|
+
*
|
|
785
|
+
* MATCHES THE EXACT PATH, not merely a command that mentions the shim's filename
|
|
786
|
+
* (`geneseed-hook`): the shim path is baked per `GENESEED_HOME`, so a machine with more than
|
|
787
|
+
* one Geneseed home (a relocated one, or a sandboxed test run beside a real install) must not
|
|
788
|
+
* let an install naming a DIFFERENT, perfectly healthy shim answer for this one.
|
|
789
|
+
*
|
|
790
|
+
* LIVES HERE, beside the `claudeCfg` and `installTargets` it is built on, because neither
|
|
791
|
+
* reader owns the install walk: doctor's `shimProblems`, and `status`'s `gateSummary`
|
|
792
|
+
* (`js/inspect/status.mjs`), which folds the same answer into its `dead` field — the one
|
|
793
|
+
* `statusLines` renders as a `DEAD` gates row. Two readers of one fact, not two copies.
|
|
794
|
+
*
|
|
795
|
+
* `targets` is a parameter, the same convention `hookShellProblems` uses, so a test can hand
|
|
796
|
+
* it a sandboxed `[host, scope, root]` triple instead of walking this machine's real config
|
|
797
|
+
* dirs and registry — the live default every production caller takes.
|
|
798
|
+
*/
|
|
799
|
+
export function installsReferencingShim(shimPath = hookShimPath(), targets = installTargets()) {
|
|
800
|
+
const out = [];
|
|
801
|
+
for (const [host, scope, root] of targets) {
|
|
802
|
+
if (!CLAUDE_STYLE.includes(host)) continue;
|
|
803
|
+
const cfg = claudeCfg(root, scope, host);
|
|
804
|
+
if (liveRecordedHooks(cfg).some(([, h]) => h.command.includes(shimPath))) out.push(cfg);
|
|
805
|
+
}
|
|
806
|
+
return out;
|
|
807
|
+
}
|
|
808
|
+
|
|
774
809
|
// ---- what this machine installed (`_harness_setup._installed_defaults`) --------------------
|
|
775
810
|
|
|
776
811
|
/**
|
package/js/hosts/native.mjs
CHANGED
|
@@ -34,6 +34,11 @@ import { jsonDumps, parseJson, formatValue, formatRepr, isTruthy, isDict } from
|
|
|
34
34
|
import { relPosix } from '../lib/text.mjs';
|
|
35
35
|
import { aliasesOf } from '../build/source.mjs';
|
|
36
36
|
import { claimLeaf, ownedState } from './owned.mjs';
|
|
37
|
+
// `lstripNewlines` on spec bodies is a no-op on every input the generator produces today
|
|
38
|
+
// (measured: no file under `src/` and no rendered spec in any theme begins with a newline), so
|
|
39
|
+
// mutating it away leaves the parity gate green — correctly, there is no reachable behaviour to
|
|
40
|
+
// detect. Kept because a spec author adding a leading blank line is one commit from needing it.
|
|
41
|
+
import { lstripNewlines } from './settings.mjs';
|
|
37
42
|
|
|
38
43
|
/** What a folder skill writes where it needs its own directory — see `writeNativeLayer`. */
|
|
39
44
|
const SKILL_DIR_PLACEHOLDER = '<this-skill-directory>';
|
|
@@ -704,16 +709,3 @@ export function writeNativeLayer(items, agentsDir, skillsDir, overrides = null,
|
|
|
704
709
|
}
|
|
705
710
|
return { nAgents, nSkills, written, userOnlySkills };
|
|
706
711
|
}
|
|
707
|
-
|
|
708
|
-
/**
|
|
709
|
-
* `str.lstrip("\n")` — only newlines, not all whitespace.
|
|
710
|
-
*
|
|
711
|
-
* Currently a no-op on every input the generator produces: measured, no file under `src/`
|
|
712
|
-
* and no rendered spec in any of the 14 themes begins with a newline. Mutating it away
|
|
713
|
-
* leaves the parity gate green, and that is correct rather than a hole — there is no
|
|
714
|
-
* reachable behaviour to detect. Kept because it is what the Python does, and a spec
|
|
715
|
-
* author adding a leading blank line is one commit away from making it matter.
|
|
716
|
-
*/
|
|
717
|
-
function lstripNewlines(text) {
|
|
718
|
-
return text.replace(/^\n+/, '');
|
|
719
|
-
}
|
package/js/hosts/opencode.mjs
CHANGED
|
@@ -28,6 +28,7 @@ import { jsonDumps, jsonDumpsIndent, parseJson } from '../lib/json.mjs';
|
|
|
28
28
|
import { comparePaths } from '../lib/paths.mjs';
|
|
29
29
|
import { sourceReleaseVersion } from '../build/version.mjs';
|
|
30
30
|
import { aliasesOf } from '../build/source.mjs';
|
|
31
|
+
import { lstripNewlines } from './settings.mjs';
|
|
31
32
|
import {
|
|
32
33
|
firstBlockquote, isUserInvokedOnly, skillDescription, stripSkillBodyLinks, pointSideFiles,
|
|
33
34
|
pushOverrideLines,
|
|
@@ -295,7 +296,7 @@ export function writePrimaryAgent(cfg, agentsDir, overrides, claim = (_dest) =>
|
|
|
295
296
|
|| !statSync(cfg.primaryAgentSrc).isFile()) {
|
|
296
297
|
return null;
|
|
297
298
|
}
|
|
298
|
-
const body = readText(cfg.primaryAgentSrc)
|
|
299
|
+
const body = lstripNewlines(readText(cfg.primaryAgentSrc));
|
|
299
300
|
const desc = 'Primary orchestrator — works by the harness Rules and delegates to the '
|
|
300
301
|
+ 'capability subagents.';
|
|
301
302
|
const fm = [`description: ${jsonDumps(desc)}`, 'mode: primary', 'color: primary'];
|
|
@@ -309,19 +310,43 @@ export function writePrimaryAgent(cfg, agentsDir, overrides, claim = (_dest) =>
|
|
|
309
310
|
return dest;
|
|
310
311
|
}
|
|
311
312
|
|
|
312
|
-
/**
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
313
|
+
/**
|
|
314
|
+
* The top-level skills of `items` (`src/skills/<stem>.md`, no `_` partials) as ordered
|
|
315
|
+
* `[stem, text]` pairs — the one parse both command writers below share.
|
|
316
|
+
*/
|
|
317
|
+
function topLevelSkills(cfg, items) {
|
|
318
|
+
/** @type {[string, string][]} */
|
|
319
|
+
const found = [];
|
|
317
320
|
for (const { text, src } of items) {
|
|
318
321
|
if (text === null) continue;
|
|
319
322
|
const sp = path.relative(cfg.src, src).split(path.sep);
|
|
320
323
|
if (sp.length === 2 && sp[0] === 'skills' && sp[1].endsWith('.md')
|
|
321
324
|
&& !sp[1].startsWith('_')) {
|
|
322
|
-
|
|
325
|
+
found.push([sp[1].slice(0, -3), text]);
|
|
323
326
|
}
|
|
324
327
|
}
|
|
328
|
+
return found;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Both command writers' default `skillDirOf`: the skill's own directory beside `commandDir`. */
|
|
332
|
+
const siblingSkillDir = (commandDir) => (n) =>
|
|
333
|
+
path.join(path.dirname(commandDir), 'skills', n).split(path.sep).join('/');
|
|
334
|
+
|
|
335
|
+
/** A command has no base directory, so a side-file pointer names the skill's own. */
|
|
336
|
+
function commandBody(text, stem, skillDir) {
|
|
337
|
+
return stripSkillBodyLinks(pointSideFiles(lstripNewlines(text), stem, skillDir));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function writeCommandFile(dest, desc, body) {
|
|
341
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
342
|
+
writeText(dest, `---\ndescription: ${jsonDumps(desc)}\n---\n\n${body}`);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** `_build_emit._write_command_layer` — the opt-in /slash commands. */
|
|
346
|
+
export function writeCommandLayer(cfg, items, commandDir, claim = (_dest) => true,
|
|
347
|
+
skillDirOf = siblingSkillDir(commandDir)) {
|
|
348
|
+
if (!truthyEnv('GENESEED_COMMANDS')) return [];
|
|
349
|
+
const byName = new Map(topLevelSkills(cfg, items));
|
|
325
350
|
const written = [];
|
|
326
351
|
for (const [name, skill] of Object.entries(COMMAND_SET)) {
|
|
327
352
|
// A command runs a skill; `--exclude-skills` took that skill out, so the command goes too.
|
|
@@ -333,10 +358,7 @@ export function writeCommandLayer(cfg, items, commandDir, claim = (_dest) => tru
|
|
|
333
358
|
const dest = path.join(commandDir, `${name}.md`);
|
|
334
359
|
if (!claim(dest)) continue;
|
|
335
360
|
const desc = firstBlockquote(text);
|
|
336
|
-
|
|
337
|
-
const body = stripSkillBodyLinks(pointSideFiles(text.replace(/^\n+/, ''), skill, skillDirOf(skill)));
|
|
338
|
-
mkdirSync(path.dirname(dest), { recursive: true });
|
|
339
|
-
writeText(dest, `---\ndescription: ${jsonDumps(desc)}\n---\n\n${body}`);
|
|
361
|
+
writeCommandFile(dest, desc, commandBody(text, skill, skillDirOf(skill)));
|
|
340
362
|
written.push(dest);
|
|
341
363
|
}
|
|
342
364
|
return written;
|
|
@@ -358,22 +380,15 @@ export function writeCommandLayer(cfg, items, commandDir, claim = (_dest) => tru
|
|
|
358
380
|
* (Task 4) — it keeps its SKILL.md and does not need a second, command-shaped copy.
|
|
359
381
|
*/
|
|
360
382
|
export function writeAliasCommands(cfg, items, commandDir, claim = (_dest) => true,
|
|
361
|
-
skillDirOf = (
|
|
362
|
-
alsoUserOnly = false) {
|
|
383
|
+
skillDirOf = siblingSkillDir(commandDir), alsoUserOnly = false) {
|
|
363
384
|
const written = [];
|
|
364
|
-
for (const
|
|
365
|
-
|
|
366
|
-
const sp = path.relative(cfg.src, src).split(path.sep);
|
|
367
|
-
if (sp.length !== 2 || sp[0] !== 'skills' || !sp[1].endsWith('.md') || sp[1].startsWith('_')) continue;
|
|
368
|
-
const stem = sp[1].slice(0, -3);
|
|
369
|
-
const body = stripSkillBodyLinks(pointSideFiles(text.replace(/^\n+/, ''), stem, skillDirOf(stem)));
|
|
385
|
+
for (const [stem, text] of topLevelSkills(cfg, items)) {
|
|
386
|
+
const body = commandBody(text, stem, skillDirOf(stem));
|
|
370
387
|
const names = alsoUserOnly && isUserInvokedOnly(text) ? [...aliasesOf(text), stem] : aliasesOf(text);
|
|
371
388
|
for (const name of names) {
|
|
372
389
|
const dest = path.join(commandDir, `${name}.md`);
|
|
373
390
|
if (!claim(dest)) continue;
|
|
374
|
-
|
|
375
|
-
const desc = name === stem ? skillDescription(text) : `Alias of ${stem}.`;
|
|
376
|
-
writeText(dest, `---\ndescription: ${jsonDumps(desc)}\n---\n\n${body}`);
|
|
391
|
+
writeCommandFile(dest, name === stem ? skillDescription(text) : `Alias of ${stem}.`, body);
|
|
377
392
|
written.push(dest);
|
|
378
393
|
}
|
|
379
394
|
}
|