geneseed 3.13.0 → 3.15.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 +93 -0
- package/SHIPPED.md +1 -1
- package/adapters/opencode/plugins/geneseed-context.js +23 -9
- package/adapters/opencode/plugins/geneseed-guard.js +16 -4
- package/adapters/opencode/plugins/geneseed-learn.js +45 -10
- package/adapters/opencode/plugins/geneseed-notify.js +37 -19
- package/adapters/opencode/plugins/geneseed-workflow.js +3 -1
- package/adapters/opencode/workflows/_runtime.js +16 -3
- package/bin/geneseed-cli.mjs +32 -34
- package/bin/geneseed-hook.mjs +28 -18
- package/docs/extending.md +5 -5
- package/harness.config.json +1 -1
- package/js/README.md +28 -15
- package/js/build/args.mjs +375 -0
- package/js/build/bundle.mjs +10 -10
- package/js/build/driver.mjs +145 -560
- package/js/build/emit-claude.mjs +5 -6
- package/js/build/emit-common.mjs +2 -14
- package/js/build/emit-opencode.mjs +12 -8
- package/js/build/generate.mjs +21 -14
- package/js/build/render.mjs +6 -10
- package/js/build/source.mjs +9 -17
- package/js/build/stubs.mjs +6 -5
- package/js/build/version.mjs +21 -2
- package/js/hosts/hooks-context.mjs +432 -0
- package/js/hosts/hooks-learn.mjs +275 -0
- package/js/hosts/hooks-prims.mjs +108 -0
- package/js/hosts/hooks.mjs +50 -977
- package/js/hosts/hosts.mjs +31 -25
- package/js/hosts/installs.mjs +38 -20
- package/js/hosts/link.mjs +75 -26
- package/js/hosts/mcp.mjs +12 -48
- package/js/hosts/memory-files.mjs +190 -0
- package/js/hosts/native.mjs +106 -48
- package/js/hosts/opencode.mjs +44 -36
- package/js/hosts/settings.mjs +135 -414
- package/js/hosts/shim.mjs +375 -0
- package/js/inspect/checks-authoring.mjs +21 -828
- package/js/inspect/checks-build.mjs +4 -4
- package/js/inspect/checks-constitution.mjs +493 -0
- package/js/inspect/checks-counts.mjs +242 -0
- package/js/inspect/checks-repo.mjs +17 -8
- package/js/inspect/diff.mjs +9 -7
- package/js/inspect/doctor.mjs +2 -2
- package/js/inspect/excludes.mjs +10 -5
- package/js/inspect/inventory.mjs +4 -4
- package/js/inspect/scan.mjs +1 -1
- package/js/inspect/status.mjs +19 -22
- package/js/inspect/validate.mjs +50 -37
- package/js/lib/fs.mjs +12 -11
- package/js/lib/json.mjs +4 -4
- package/js/lib/paths.mjs +27 -33
- package/js/lib/proc.mjs +3 -3
- package/js/lib/prompt.mjs +132 -0
- package/js/lib/text.mjs +18 -4
- package/js/lib/udiff.mjs +3 -2
- package/js/loop/catalog.mjs +5 -3
- package/js/loop/cli.mjs +26 -12
- package/js/loop/graph.mjs +1 -1
- package/js/loop/registry.mjs +10 -15
- package/js/loop/score.mjs +1 -1
- package/js/loop/state.mjs +35 -11
- package/js/maintain/memory.mjs +6 -7
- package/js/maintain/migrate.mjs +22 -33
- package/js/maintain/setup.mjs +9 -131
- package/js/maintain/uninstall.mjs +57 -97
- package/js/maintain/update.mjs +115 -34
- package/js/ui/anim.mjs +8 -12
- package/js/ui/cli.mjs +37 -43
- package/js/ui/menu.mjs +7 -13
- package/js/ui/tui.mjs +14 -123
- package/js/web/actions.mjs +109 -403
- package/js/web/activity.mjs +1 -1
- package/js/web/api.mjs +41 -725
- package/js/web/catalog.mjs +527 -0
- package/js/web/daemon.mjs +21 -3
- package/js/web/docs.mjs +29 -72
- package/js/web/handler.mjs +51 -27
- package/js/web/jobs.mjs +24 -2
- package/js/web/routes.mjs +61 -6
- package/js/web/server.mjs +4 -4
- package/js/web/state.mjs +138 -0
- package/js/web/user-files.mjs +339 -0
- package/package.json +1 -1
- package/web/dist/assets/Activity-kpWxd70F.js +1 -0
- package/web/dist/assets/ActivityDetail-Ccr942VL.js +1 -0
- package/web/dist/assets/Dashboard-D1lE7jcg.js +2 -0
- package/web/dist/assets/Docs-BHyaggFy.js +5 -0
- package/web/dist/assets/FilterInput-CVyqmYWM.js +1 -0
- package/web/dist/assets/GreenhouseView-Sc_UDMOE.js +2 -0
- package/web/dist/assets/Installs-Gw-pJ3Zi.js +2 -0
- package/web/dist/assets/JournalView-_gxpsxXi.js +1 -0
- package/web/dist/assets/Laws-S9fq1r9T.js +3 -0
- package/web/dist/assets/Library-MBxadRzn.js +1 -0
- package/web/dist/assets/Loops-Da_JhlzM.js +2 -0
- package/web/dist/assets/{Markdown-CdTX0kLe.js → Markdown-X2a3qvOh.js} +11 -11
- package/web/dist/assets/OperatorHudView--TkvDybH.js +2 -0
- package/web/dist/assets/Personal-CTXKHVbV.js +1 -0
- package/web/dist/assets/Tabs-B0cdf8k6.js +1 -0
- package/web/dist/assets/activity-Dr7ZMBI3.js +1 -0
- package/web/dist/assets/api-CxgfYu18.js +1 -0
- package/web/dist/assets/drawable-BbWQxe82.js +1 -0
- package/web/dist/assets/index-Ctp-4poP.css +1 -0
- package/web/dist/assets/index-ujKG1ZNf.js +2 -0
- package/web/dist/assets/{react-Bbzhmq4v.js → react-B9Z_tXRV.js} +1 -1
- package/web/dist/assets/sections-GalMS0Zv.js +1 -0
- package/web/dist/assets/usePoll-DD8VjtSg.js +1 -0
- package/web/dist/index.html +5 -6
- package/web/src/pages/Laws.jsx +0 -1
- package/web/dist/assets/Activity-Ca2Aruu7.js +0 -1
- package/web/dist/assets/ActivityDetail-Bo7v4Z_r.js +0 -1
- package/web/dist/assets/Dashboard-BuMq46aT.js +0 -2
- package/web/dist/assets/Docs-BzccCllG.js +0 -5
- package/web/dist/assets/FilterInput-CBcJBQPI.js +0 -1
- package/web/dist/assets/GreenhouseView-BCIKcnGG.js +0 -2
- package/web/dist/assets/Installs-DMFJvq7s.js +0 -2
- package/web/dist/assets/JournalView-o1rmd1_1.js +0 -1
- package/web/dist/assets/Laws-BANxq_I_.js +0 -3
- package/web/dist/assets/Library-DW4x0fbh.js +0 -1
- package/web/dist/assets/Loops-DEBibbBP.js +0 -2
- package/web/dist/assets/OperatorHudView-CLD0fYaA.js +0 -2
- package/web/dist/assets/Personal-6uMJvyBG.js +0 -1
- package/web/dist/assets/Tabs-BubnyZhF.js +0 -1
- package/web/dist/assets/activity-CObqyxkL.js +0 -1
- package/web/dist/assets/api-D_ixdyEY.js +0 -1
- package/web/dist/assets/drawable-C_x5xncq.js +0 -1
- package/web/dist/assets/index-CMPgezFm.js +0 -2
- package/web/dist/assets/index-DMsT4Aa6.css +0 -1
- package/web/dist/assets/rolldown-runtime-hePW80VL.js +0 -1
- package/web/dist/assets/sections-Dg23Hel5.js +0 -1
- package/web/dist/assets/usePoll-BOqeunzF.js +0 -1
package/js/web/actions.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The web console's WRITES — the mutating endpoints, plus the
|
|
3
|
-
*
|
|
2
|
+
* The web console's WRITES — the mutating endpoints, plus the one GET that belongs beside them
|
|
3
|
+
* (`apiMcp`). The fingerprint-guarded rules and profile editors are `user-files.mjs`.
|
|
4
4
|
*
|
|
5
5
|
* `{cmd: [...]}`, returned by `apiInstallCmd` and `apiDeployCmd` below, IS A FUNCTION'S
|
|
6
6
|
* RETURN TYPE, NOT A RESPONSE BODY: the dispatcher hands it straight to the job runner in
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
*/
|
|
28
28
|
import { spawn } from 'node:child_process';
|
|
29
29
|
import {
|
|
30
|
-
accessSync, constants, copyFileSync, mkdirSync, mkdtempSync, rmSync, unlinkSync,
|
|
30
|
+
accessSync, constants, copyFileSync, mkdirSync, mkdtempSync, realpathSync, rmSync, unlinkSync,
|
|
31
31
|
} from 'node:fs';
|
|
32
32
|
import os from 'node:os';
|
|
33
33
|
import path from 'node:path';
|
|
@@ -40,34 +40,54 @@ import { withStdoutSwallowed } from '../inspect/diff.mjs';
|
|
|
40
40
|
import { excludeAdd, excludeRemove } from '../inspect/excludes.mjs';
|
|
41
41
|
import { setupBuildArgs } from '../build/generate.mjs';
|
|
42
42
|
import { DEFAULT_PRESET, PRESETS } from '../loop/score.mjs';
|
|
43
|
-
import { setLoopPreset } from '../loop/registry.mjs';
|
|
44
|
-
import { frontmatter, memoryDropIndex } from '../hosts/hooks.mjs';
|
|
43
|
+
import { activeLoops, setLoopPreset } from '../loop/registry.mjs';
|
|
45
44
|
import {
|
|
46
|
-
HOSTS, bobConfigDir, claudeConfigDir, expanduser, openclaudeConfigDir,
|
|
45
|
+
CLAUDE_STYLE, HOSTS, bobConfigDir, claudeConfigDir, expanduser, openclaudeConfigDir,
|
|
47
46
|
opencodeConfigDir, resolvePath,
|
|
48
47
|
} from '../hosts/hosts.mjs';
|
|
49
48
|
import {
|
|
50
49
|
EMIT_HOST_SCOPE, doctrinesForBuild, excludedRulesOfDir, footprintOfDir, installState,
|
|
51
|
-
installTargets, modeOfDir, postureOfDir,
|
|
50
|
+
installTargets, modeOfDir, postureOfDir, trustOfDir,
|
|
52
51
|
} from '../hosts/installs.mjs';
|
|
53
52
|
import {
|
|
54
53
|
MCP_PRESETS, isDict, mcpApply, mcpCommented, mcpInstallTargets, mcpKnownNames, mcpLoad,
|
|
55
54
|
mcpMeta, mcpPresetBlock, mcpSave, mcpSetEnabled, mcpState,
|
|
56
55
|
} from '../hosts/mcp.mjs';
|
|
57
|
-
import { readText,
|
|
56
|
+
import { readText, isFile, isDir } from '../lib/fs.mjs';
|
|
58
57
|
import { parseJson, formatRepr, formatValue, isTruthy } from '../lib/json.mjs';
|
|
59
58
|
import { NO_WINDOW } from '../lib/proc.mjs';
|
|
60
|
-
import {
|
|
59
|
+
import { normcase, which, within } from '../lib/paths.mjs';
|
|
60
|
+
import { stripWhitespace } from '../lib/text.mjs';
|
|
61
|
+
import { memoryDelete } from '../maintain/memory.mjs';
|
|
61
62
|
import { installDeactivate, installReactivate, installUninstall } from '../maintain/uninstall.mjs';
|
|
62
|
-
import {
|
|
63
|
-
import {
|
|
64
|
-
NotFound, deployed, emitChoices, fingerprint, themeChoices, viewCfg, within,
|
|
65
|
-
} from './api.mjs';
|
|
63
|
+
import { emitChoices, themeChoices, viewCfg } from './api.mjs';
|
|
64
|
+
import { NotFound, deployed, memoryDir } from './catalog.mjs';
|
|
66
65
|
|
|
67
|
-
|
|
66
|
+
/** One body field, `dflt` when the body is not an object or does not carry the key. */
|
|
67
|
+
export const bget = (body, key, dflt = null) => (isDict(body) && Object.hasOwn(body, key)
|
|
68
68
|
? body[key] : dflt);
|
|
69
69
|
|
|
70
|
-
const strOr = (v) => (isTruthy(v) ? formatValue(v) : '');
|
|
70
|
+
export const strOr = (v) => (isTruthy(v) ? formatValue(v) : '');
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A request body's LIST field closed against `known()` member by member, re-ordered through
|
|
74
|
+
* `order()`, or `null` for "said nothing usable" — the shared shape of the two doctrine axes
|
|
75
|
+
* below. `norm` rewrites a member before the check (the rule axis accepts `process 7`).
|
|
76
|
+
*
|
|
77
|
+
* `known`/`order` are thunks so a body that names nothing never pays for discovery.
|
|
78
|
+
*/
|
|
79
|
+
function bodyList(body, key, known, order = known, norm = (n) => n) {
|
|
80
|
+
const raw = bget(body, key);
|
|
81
|
+
if (Array.isArray(raw) && !raw.length) return [];
|
|
82
|
+
const names = Array.isArray(raw) ? raw
|
|
83
|
+
: (typeof raw === 'string' ? raw.split(',').map((s) => s.trim()).filter(Boolean) : null);
|
|
84
|
+
if (names === null || !names.length) return null;
|
|
85
|
+
if (names.length === 1 && names[0] === 'none') return [];
|
|
86
|
+
const ids = names.map((n) => (typeof n === 'string' ? norm(n) : n));
|
|
87
|
+
const ok = known();
|
|
88
|
+
if (!ids.every((n) => typeof n === 'string' && ok.includes(n))) return null;
|
|
89
|
+
return order().filter((id) => ids.includes(id));
|
|
90
|
+
}
|
|
71
91
|
|
|
72
92
|
/**
|
|
73
93
|
* A request body's `doctrines` -> a normalised pack list, or `null` for "said nothing usable".
|
|
@@ -88,15 +108,8 @@ const strOr = (v) => (isTruthy(v) ? formatValue(v) : '');
|
|
|
88
108
|
* `none`/`[]` both mean the deliberate empty selection — a real configuration, not a rejection.
|
|
89
109
|
*/
|
|
90
110
|
function bodyDoctrines(body) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const names = Array.isArray(raw) ? raw
|
|
94
|
-
: (typeof raw === 'string' ? raw.split(',').map((s) => s.trim()).filter(Boolean) : null);
|
|
95
|
-
if (names === null || !names.length) return null;
|
|
96
|
-
if (names.length === 1 && names[0] === 'none') return [];
|
|
97
|
-
const known = discoverNames('doctrines', PACK_ORDER[0]);
|
|
98
|
-
if (!names.every((n) => typeof n === 'string' && known.includes(n))) return null;
|
|
99
|
-
return PACK_ORDER.filter((p) => names.includes(p));
|
|
111
|
+
return bodyList(body, 'doctrines', () => discoverNames('doctrines', PACK_ORDER[0]),
|
|
112
|
+
() => PACK_ORDER);
|
|
100
113
|
}
|
|
101
114
|
|
|
102
115
|
/**
|
|
@@ -113,47 +126,44 @@ function bodyDoctrines(body) {
|
|
|
113
126
|
* selection: "exclude nothing", which is a real answer and NOT the same as not saying.
|
|
114
127
|
*/
|
|
115
128
|
function bodyExcludeRules(body) {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const names = Array.isArray(raw) ? raw
|
|
119
|
-
: (typeof raw === 'string' ? raw.split(',').map((s) => s.trim()).filter(Boolean) : null);
|
|
120
|
-
if (names === null || !names.length) return null;
|
|
121
|
-
if (names.length === 1 && names[0] === 'none') return [];
|
|
122
|
-
const known = knownRuleIds();
|
|
123
|
-
const ids = names.map((n) => (typeof n === 'string' ? n.trim().replace(/[ \t]+/, '.') : n));
|
|
124
|
-
if (!ids.every((n) => typeof n === 'string' && known.includes(n))) return null;
|
|
125
|
-
return known.filter((id) => ids.includes(id));
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** Splits on runs of `WHITESPACE` (not `\s`), dropping empty strings. */
|
|
129
|
-
const splitWords = (s) => s.split(new RegExp(`[${WHITESPACE}]+`)).filter(Boolean);
|
|
130
|
-
|
|
131
|
-
/** Today's date, LOCAL (not UTC) — this is a user-facing date, not a wire timestamp. */
|
|
132
|
-
function todayIso(d = new Date()) {
|
|
133
|
-
const p2 = (n) => String(n).padStart(2, '0');
|
|
134
|
-
return `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())}`;
|
|
129
|
+
return bodyList(body, 'excludeRules', knownRuleIds, knownRuleIds,
|
|
130
|
+
(n) => n.trim().replace(/[ \t]+/, '.'));
|
|
135
131
|
}
|
|
136
132
|
|
|
137
133
|
/**
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
* is the worst kind to debug.
|
|
134
|
+
* The five scalar axes of a build request — theme, footprint, posture, mode, trust — each
|
|
135
|
+
* taken from the body only when it names a value this checkout ships, else from `dflt`. The
|
|
136
|
+
* SAME allowlist for `apiInstallCmd` and `apiDeployCmd`; they differ only in their defaults (the
|
|
137
|
+
* install's own markers, versus a fresh deploy's). A bogus body value never reaches the argv.
|
|
143
138
|
*/
|
|
144
|
-
function
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
139
|
+
function bodyAxes(body, dflt) {
|
|
140
|
+
const pick = (key, ok) => { const v = bget(body, key); return ok(v) ? v : dflt[key]; };
|
|
141
|
+
return {
|
|
142
|
+
theme: pick('theme', (v) => themeChoices().some((c) => c.name === v)),
|
|
143
|
+
footprint: pick('footprint', (v) => v === 'lean' || v === 'full'),
|
|
144
|
+
posture: pick('posture', (v) => discoverNames('postures', 'peer').includes(v)),
|
|
145
|
+
mode: pick('mode', (v) => discoverNames('modes', 'direct').includes(v)),
|
|
146
|
+
trust: pick('trust', (v) => Object.keys(PRESETS).includes(v)),
|
|
147
|
+
};
|
|
149
148
|
}
|
|
150
149
|
|
|
151
150
|
/**
|
|
152
|
-
*
|
|
153
|
-
* `
|
|
154
|
-
*
|
|
151
|
+
* The DETECTED install a body's (host, path) pair names — `[host, scope, root]` — or a
|
|
152
|
+
* `NotFound`. THE ALLOWLIST three endpoints share (view select, rebuild, deactivate/remove), and
|
|
153
|
+
* one copy of it: two copies of a security check drift, and the copy that drifts is the one
|
|
154
|
+
* nobody re-read.
|
|
155
|
+
*
|
|
156
|
+
* The pair is SERIALISED into the key rather than concatenated with a separator: the path comes
|
|
157
|
+
* straight out of a request body, and any separator it could contain would let one pair
|
|
158
|
+
* impersonate another. `findLast` keeps the old Map's last-write-wins on a duplicate pair.
|
|
155
159
|
*/
|
|
156
|
-
|
|
160
|
+
function detectedInstall(body) {
|
|
161
|
+
const want = JSON.stringify([strOr(bget(body, 'host')), strOr(bget(body, 'path'))]);
|
|
162
|
+
const hit = installTargets()
|
|
163
|
+
.findLast(([host, , root]) => JSON.stringify([host, String(root)]) === want);
|
|
164
|
+
if (hit === undefined) throw new NotFound('unknown install (host, path)');
|
|
165
|
+
return hit;
|
|
166
|
+
}
|
|
157
167
|
|
|
158
168
|
// ---- OS-native folder picker ---------------------------------------------------------------
|
|
159
169
|
|
|
@@ -244,7 +254,10 @@ function runPicker(cmd, args, done, resultOf) {
|
|
|
244
254
|
*/
|
|
245
255
|
export function apiPickFolder(done) {
|
|
246
256
|
if (process.platform === 'win32') {
|
|
247
|
-
|
|
257
|
+
// `which`, never a bare name: Windows resolves a bare name against the cwd first.
|
|
258
|
+
const powershell = which('powershell');
|
|
259
|
+
if (!powershell) return done({ error: 'folder picker unavailable: powershell not on PATH' });
|
|
260
|
+
return runPicker(powershell, ['-NoProfile', '-STA', '-Command', PICK_SCRIPT_WIN], done,
|
|
248
261
|
(code, out, err) => {
|
|
249
262
|
if (code !== 0) return { error: (err || out).trim() || 'folder picker failed' };
|
|
250
263
|
const p = out.trim();
|
|
@@ -261,279 +274,8 @@ export function apiPickFolder(done) {
|
|
|
261
274
|
return done({ error: 'no native folder dialog on this platform' });
|
|
262
275
|
}
|
|
263
276
|
|
|
264
|
-
// ---- user rules (user-rules.md) ----------------------------------------------------------
|
|
265
|
-
|
|
266
|
-
export const RULES_FILE = 'user-rules.md';
|
|
267
|
-
const rulesPath = (state) => path.join(state.target, RULES_FILE);
|
|
268
|
-
|
|
269
|
-
/**
|
|
270
|
-
* `## R<n> — Title`, anchored at column 0 so a rule body's fenced code and the stub's
|
|
271
|
-
* indented format example never parse as rules.
|
|
272
|
-
*
|
|
273
|
-
* The whitespace classes are `WHITESPACE`, not `\s`: this pattern runs over a HAND-EDITED
|
|
274
|
-
* file, which is where a non-breaking space actually turns up. `\S` is its complement for
|
|
275
|
-
* the same reason.
|
|
276
|
-
*
|
|
277
|
-
* `\d` IS LEFT ASCII, and that is a declared divergence rather than an oversight, unlike
|
|
278
|
-
* `ruleFields` below which uses `\p{Nd}`. No theme seeds a non-ASCII rule id, no build
|
|
279
|
-
* writes one, and the web editor cannot produce one either — it writes `R<n>` itself from
|
|
280
|
-
* `max(ids) + 1`. Widening this to match would only move the gap one line down, to whatever
|
|
281
|
-
* parses the captured digits back into a number.
|
|
282
|
-
*/
|
|
283
|
-
const RULE_HEAD_RE = new RegExp(
|
|
284
|
-
`^##[${WHITESPACE}]+R(\\d+)[${WHITESPACE}]*[—–-]+[${WHITESPACE}]*`
|
|
285
|
-
+ `([^${WHITESPACE}].*?)[${WHITESPACE}]*$`,
|
|
286
|
-
);
|
|
287
|
-
|
|
288
|
-
/** Advisory only; nothing blocks past it. */
|
|
289
|
-
const RULES_BUDGET = { max_rules: 15, max_tokens: 1500 };
|
|
290
|
-
|
|
291
|
-
const META_RE = new RegExp(`^\\((.+)\\)[${WHITESPACE}]*$`);
|
|
292
|
-
|
|
293
|
-
/** `{}` when the line is body rather than metadata. */
|
|
294
|
-
function parseRuleMeta(line) {
|
|
295
|
-
const m = META_RE.exec(stripWhitespace(line));
|
|
296
|
-
if (!m) return {};
|
|
297
|
-
const meta = {};
|
|
298
|
-
for (const part of m[1].split('|')) {
|
|
299
|
-
const at = part.indexOf(':');
|
|
300
|
-
if (at < 0) continue; // `partition` with no separator — not a pair
|
|
301
|
-
const key = stripWhitespace(part.slice(0, at)).toLowerCase().replaceAll(' ', '_');
|
|
302
|
-
if (['scope', 'source', 'trial_until'].includes(key)) {
|
|
303
|
-
meta[key] = stripWhitespace(part.slice(at + 1));
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
return meta;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
/**
|
|
310
|
-
* `user-rules.md` parsed to `[rules, warnings]`.
|
|
311
|
-
*
|
|
312
|
-
* Every rule carries its `start`/`end` LINE INDICES, which is what lets `apiRulesMutate`
|
|
313
|
-
* splice exactly one block and leave every other byte of the user's file — prose,
|
|
314
|
-
* formatting, hand-written sections — untouched.
|
|
315
|
-
*/
|
|
316
|
-
export function parseRules(text) {
|
|
317
|
-
const lines = splitLines(text);
|
|
318
|
-
const rules = [];
|
|
319
|
-
const warnings = [];
|
|
320
|
-
const seen = new Set();
|
|
321
|
-
for (let idx = 0; idx < lines.length; idx += 1) {
|
|
322
|
-
const m = RULE_HEAD_RE.exec(lines[idx]);
|
|
323
|
-
if (!m) continue;
|
|
324
|
-
let end = lines.length;
|
|
325
|
-
for (let j = idx + 1; j < lines.length; j += 1) {
|
|
326
|
-
if (lines[j].startsWith('## ')) { end = j; break; }
|
|
327
|
-
}
|
|
328
|
-
const rid = Number(m[1]);
|
|
329
|
-
if (seen.has(rid)) warnings.push(`duplicate rule id R${rid}`);
|
|
330
|
-
seen.add(rid);
|
|
331
|
-
let k = idx + 1;
|
|
332
|
-
while (k < end && !stripWhitespace(lines[k])) k += 1;
|
|
333
|
-
const meta = k < end ? parseRuleMeta(lines[k]) : {};
|
|
334
|
-
if (Object.keys(meta).length) k += 1;
|
|
335
|
-
rules.push({
|
|
336
|
-
id: rid,
|
|
337
|
-
title: m[2],
|
|
338
|
-
scope: Object.hasOwn(meta, 'scope') ? meta.scope : 'project',
|
|
339
|
-
source: Object.hasOwn(meta, 'source') ? meta.source : '',
|
|
340
|
-
trial_until: Object.hasOwn(meta, 'trial_until') ? meta.trial_until : '',
|
|
341
|
-
body: stripWhitespace(lines.slice(k, end).join('\n')),
|
|
342
|
-
start: idx,
|
|
343
|
-
end,
|
|
344
|
-
});
|
|
345
|
-
}
|
|
346
|
-
return [rules, warnings];
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
export function apiRules(state) {
|
|
350
|
-
const p = rulesPath(state);
|
|
351
|
-
if (!isFile(p)) {
|
|
352
|
-
return { exists: false, path: p, rules: [], warnings: [], fingerprint: '',
|
|
353
|
-
stats: { rules: 0, lines: 0, tokens: 0, ...RULES_BUDGET } };
|
|
354
|
-
}
|
|
355
|
-
const text = readMaybe(p) ?? '';
|
|
356
|
-
const [rules, warnings] = parseRules(text);
|
|
357
|
-
const today = todayIso();
|
|
358
|
-
const out = rules.map((r) => ({
|
|
359
|
-
id: r.id, title: r.title, scope: r.scope, source: r.source,
|
|
360
|
-
trial_until: r.trial_until,
|
|
361
|
-
status: r.trial_until ? 'trial' : 'active',
|
|
362
|
-
overdue: Boolean(r.trial_until) && r.trial_until < today,
|
|
363
|
-
body: r.body,
|
|
364
|
-
}));
|
|
365
|
-
return { exists: true, path: p, rules: out, warnings, fingerprint: fingerprint(text),
|
|
366
|
-
// CODE POINTS, not `String.length` (UTF-16 units) — an emoji in a rule body would
|
|
367
|
-
// otherwise put the token estimate off.
|
|
368
|
-
stats: { rules: rules.length, lines: splitLines(text).length,
|
|
369
|
-
tokens: Math.floor(codePointLength(text) / 4), ...RULES_BUDGET } };
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
function rulesRead(state) {
|
|
373
|
-
const p = rulesPath(state);
|
|
374
|
-
return [p, isFile(p) ? (readMaybe(p) ?? '') : ''];
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
function ruleBlock(rid, title, scope, source, trialUntil, body) {
|
|
378
|
-
const meta = [`scope: ${scope}`];
|
|
379
|
-
if (source) meta.push(`source: ${source}`);
|
|
380
|
-
if (trialUntil) meta.push(`trial until: ${trialUntil}`);
|
|
381
|
-
return `## R${rid} — ${title}\n(${meta.join(' | ')})\n${stripWhitespace(body)}\n`;
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
/**
|
|
385
|
-
* Validate and normalise a rule's writable fields. Throws (→ the shell's JSON 500 carrying
|
|
386
|
-
* the message) on an unusable rule.
|
|
387
|
-
*
|
|
388
|
-
* `\p{Nd}` here and not `\d`, unlike `RULE_HEAD_RE` above: this is a pure predicate over a
|
|
389
|
-
* string that stays a string, so accepting any Unicode decimal digit costs nothing and
|
|
390
|
-
* needs no numeric parse afterward.
|
|
391
|
-
*/
|
|
392
|
-
function ruleFields(body) {
|
|
393
|
-
const title = splitWords(strOr(bget(body, 'title'))).join(' ');
|
|
394
|
-
const text = stripWhitespace(strOr(bget(body, 'body')));
|
|
395
|
-
if (!title) throw new Error('a rule needs a title');
|
|
396
|
-
if (!text) throw new Error('a rule needs body text');
|
|
397
|
-
let scope = bget(body, 'scope');
|
|
398
|
-
if (scope !== 'user' && scope !== 'project') scope = 'project';
|
|
399
|
-
const source = splitWords(strOr(bget(body, 'source'))).join(' ');
|
|
400
|
-
const trial = stripWhitespace(strOr(bget(body, 'trial_until')));
|
|
401
|
-
if (trial && !/^\p{Nd}{4}-\p{Nd}{2}-\p{Nd}{2}$/u.test(trial)) {
|
|
402
|
-
throw new Error('trial until must be YYYY-MM-DD');
|
|
403
|
-
}
|
|
404
|
-
return [title, text, scope, source, trial];
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
/**
|
|
408
|
-
* Coerces whatever `id` came out of the parsed body into an integer, or throws
|
|
409
|
-
* `NotFound('rule id')`. Handles a decimal string (surrounding whitespace stripped), a
|
|
410
|
-
* boolean, a bare number (truncated), and a `JsonNumber`-shaped value via its `valueOf()` —
|
|
411
|
-
* whatever `parseJson` could have produced for this field.
|
|
412
|
-
*/
|
|
413
|
-
function ruleId(v) {
|
|
414
|
-
if (typeof v === 'string') {
|
|
415
|
-
const s = stripWhitespace(v);
|
|
416
|
-
if (!/^[+-]?\d+$/.test(s)) throw new NotFound('rule id');
|
|
417
|
-
return Number(s);
|
|
418
|
-
}
|
|
419
|
-
if (typeof v === 'boolean') return v ? 1 : 0;
|
|
420
|
-
if (typeof v === 'number') return Math.trunc(v);
|
|
421
|
-
if (v !== null && v !== undefined && typeof v.valueOf === 'function'
|
|
422
|
-
&& typeof v.valueOf() === 'number') {
|
|
423
|
-
return Math.trunc(v.valueOf());
|
|
424
|
-
}
|
|
425
|
-
throw new NotFound('rule id');
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
/**
|
|
429
|
-
* Add / update / delete on user-rules.md.
|
|
430
|
-
*
|
|
431
|
-
* EVERY MUTATION REQUIRES THE FINGERPRINT OF THE CONTENT THE CLIENT LAST READ. An agent
|
|
432
|
-
* session may be editing the same file mid-flight, so a stale write returns `ok: false`
|
|
433
|
-
* (which the shell maps to 409) and the client re-fetches — it never clobbers. The fresh
|
|
434
|
-
* fingerprint comes back in the response so a client can chain edits without a re-fetch.
|
|
435
|
-
*/
|
|
436
|
-
export function apiRulesMutate(state, body) {
|
|
437
|
-
const op = bget(body, 'op');
|
|
438
|
-
if (op !== 'add' && op !== 'update' && op !== 'delete') {
|
|
439
|
-
throw new NotFound(`rules op ${formatRepr(op)}`);
|
|
440
|
-
}
|
|
441
|
-
const [p, original] = rulesRead(state);
|
|
442
|
-
let text = original;
|
|
443
|
-
if (!fpMatches(bget(body, 'fingerprint', ''), fingerprint(text))) {
|
|
444
|
-
return { ok: false, error: 'conflict',
|
|
445
|
-
detail: 'user-rules.md changed since you loaded it — reloading' };
|
|
446
|
-
}
|
|
447
|
-
const [rules] = parseRules(text);
|
|
448
|
-
let rid;
|
|
449
|
-
let next;
|
|
450
|
-
if (op === 'add') {
|
|
451
|
-
const [title, rtext, scope, source, trial] = ruleFields(body);
|
|
452
|
-
rid = rules.reduce((mx, r) => (r.id > mx ? r.id : mx), 0) + 1;
|
|
453
|
-
if (!text) text = '# User rules\n';
|
|
454
|
-
next = `${text.replace(/\n+$/, '')}\n\n${ruleBlock(rid, title, scope, source, trial, rtext)}`;
|
|
455
|
-
} else {
|
|
456
|
-
rid = ruleId(bget(body, 'id'));
|
|
457
|
-
const target = rules.find((r) => r.id === rid);
|
|
458
|
-
if (target === undefined) throw new NotFound(`rule R${rid}`);
|
|
459
|
-
const lines = splitLines(text);
|
|
460
|
-
if (op === 'update') {
|
|
461
|
-
const [title, rtext, scope, source, trial] = ruleFields(body);
|
|
462
|
-
const block = ruleBlock(rid, title, scope, source, trial, rtext);
|
|
463
|
-
lines.splice(target.start, target.end - target.start,
|
|
464
|
-
...splitLines(block.replace(/\n+$/, '')));
|
|
465
|
-
} else {
|
|
466
|
-
// delete — also swallow ONE preceding blank separator line, or repeated deletes leave
|
|
467
|
-
// a growing run of blank lines in the user's file.
|
|
468
|
-
let start = target.start;
|
|
469
|
-
if (start > 0 && !stripWhitespace(lines[start - 1])) start -= 1;
|
|
470
|
-
lines.splice(start, target.end - start);
|
|
471
|
-
}
|
|
472
|
-
next = `${lines.join('\n').replace(/\n+$/, '')}\n`;
|
|
473
|
-
}
|
|
474
|
-
writeText(p, next);
|
|
475
|
-
return { ok: true, op, id: rid, fingerprint: fingerprint(next) };
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
/**
|
|
479
|
-
* One memory fact promoted into a trial rule. The provenance line and the month of
|
|
480
|
-
* probation are the rule this endpoint owns, and both are DATES read from the real clock —
|
|
481
|
-
* not destamped, since destamping would erase exactly the thing being recorded.
|
|
482
|
-
*/
|
|
483
|
-
export function apiRulesPromote(state, body) {
|
|
484
|
-
const name = strOr(bget(body, 'name'));
|
|
485
|
-
const d = memoryDir(state);
|
|
486
|
-
if (!isDir(d)) throw new NotFound('memory store');
|
|
487
|
-
if (!name || name.includes('/') || name.includes('\\')
|
|
488
|
-
|| name === 'MEMORY' || name === 'README') {
|
|
489
|
-
throw new NotFound(name);
|
|
490
|
-
}
|
|
491
|
-
const src = path.join(d, `${name}.md`);
|
|
492
|
-
if (!isFile(src)) throw new NotFound(name);
|
|
493
|
-
const [fm, memBody] = frontmatter(readMaybe(src) ?? '');
|
|
494
|
-
const fmName = fm.has('name') ? fm.get('name') : name;
|
|
495
|
-
const fmDesc = fm.has('description') ? fm.get('description') : '';
|
|
496
|
-
const [today, trial] = promoteDates(30);
|
|
497
|
-
const res = apiRulesMutate(state, {
|
|
498
|
-
op: 'add',
|
|
499
|
-
fingerprint: bget(body, 'fingerprint', ''),
|
|
500
|
-
title: isTruthy(bget(body, 'title')) ? bget(body, 'title') : fmName,
|
|
501
|
-
body: isTruthy(bget(body, 'body')) ? bget(body, 'body')
|
|
502
|
-
: (stripWhitespace(memBody) || fmDesc),
|
|
503
|
-
scope: bget(body, 'scope'),
|
|
504
|
-
source: `memory ${name}, promoted ${today}`,
|
|
505
|
-
trial_until: trial,
|
|
506
|
-
});
|
|
507
|
-
if (isTruthy(res.ok) && isTruthy(bget(body, 'delete_memory'))) {
|
|
508
|
-
apiMemoryDelete(state, name);
|
|
509
|
-
res.deleted_memory = name;
|
|
510
|
-
}
|
|
511
|
-
return res;
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
// ---- PROFILE.md --------------------------------------------------------------------------
|
|
515
|
-
|
|
516
|
-
/** The whole file, fingerprint-guarded like the rules above. */
|
|
517
|
-
export function apiProfileSave(state, body) {
|
|
518
|
-
const p = path.join(state.target, 'PROFILE.md');
|
|
519
|
-
const cur = isFile(p) ? (readMaybe(p) ?? '') : '';
|
|
520
|
-
if (!fpMatches(bget(body, 'fingerprint', ''), fingerprint(cur))) {
|
|
521
|
-
return { ok: false, error: 'conflict',
|
|
522
|
-
detail: 'PROFILE.md changed since you loaded it — reloading' };
|
|
523
|
-
}
|
|
524
|
-
let next = strOr(bget(body, 'text'));
|
|
525
|
-
if (next && !next.endsWith('\n')) next += '\n';
|
|
526
|
-
writeText(p, next);
|
|
527
|
-
// The fingerprint of what is ON DISK, which is not the fingerprint of what was SENT once
|
|
528
|
-
// the newline has been appended — and the client chains its next save off this value.
|
|
529
|
-
return { ok: true, path: p, fingerprint: fingerprint(next) };
|
|
530
|
-
}
|
|
531
|
-
|
|
532
277
|
// ---- memory ------------------------------------------------------------------------------
|
|
533
278
|
|
|
534
|
-
/** Always `<target>/memory` — never the CWD-scanning resolver. */
|
|
535
|
-
const memoryDir = (state) => path.join(state.target, 'memory');
|
|
536
|
-
|
|
537
279
|
/**
|
|
538
280
|
* One fact file, and its line in the MEMORY.md index.
|
|
539
281
|
*
|
|
@@ -546,14 +288,10 @@ const memoryDir = (state) => path.join(state.target, 'memory');
|
|
|
546
288
|
export function apiMemoryDelete(state, name) {
|
|
547
289
|
const d = memoryDir(state);
|
|
548
290
|
if (!isDir(d)) throw new NotFound('memory store');
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
const p = path.join(d, `${name}.md`);
|
|
554
|
-
if (!isFile(p)) throw new NotFound(name);
|
|
555
|
-
unlinkSync(p);
|
|
556
|
-
memoryDropIndex(d, name);
|
|
291
|
+
// `memoryDelete` is the `geneseed memory rm` engine, and its reserved-name check is
|
|
292
|
+
// CASE-FOLDED: this endpoint's own compared `MEMORY` exactly, so on Windows and macOS `memory`
|
|
293
|
+
// resolved to MEMORY.md, passed, and deleted the index the agent reads at session start.
|
|
294
|
+
if (!memoryDelete(d, name)) throw new NotFound(name);
|
|
557
295
|
state.refresh();
|
|
558
296
|
return { deleted: name };
|
|
559
297
|
}
|
|
@@ -582,24 +320,30 @@ export function apiExcludesMutate(state, body) {
|
|
|
582
320
|
|
|
583
321
|
/**
|
|
584
322
|
* The Active tab's one write: rewrite a registered worktree's `LOOP.md` `preset` field.
|
|
585
|
-
* `
|
|
586
|
-
* a
|
|
587
|
-
* `
|
|
588
|
-
*
|
|
589
|
-
*
|
|
590
|
-
*
|
|
591
|
-
*
|
|
592
|
-
*
|
|
323
|
+
* `POST /api/loops/preset` makes two refusals — a `preset` outside `PRESETS` and a `root` that is
|
|
324
|
+
* not a running registered loop (no arbitrary path writes) — and both are decided HERE, as the
|
|
325
|
+
* `NotFound` every other mutating route answers in, so the handler's catch (`4xx`, not the 500 a
|
|
326
|
+
* bare `Error` would fall to) covers this route the way it covers `apiRulesMutate`'s unknown id.
|
|
327
|
+
*
|
|
328
|
+
* DECIDED, NOT RECOGNISED. This route used to call `setLoopPreset` and sort its throws by their
|
|
329
|
+
* MESSAGE text, so rewording an error in `js/loop/registry.mjs` would have turned a 404 into a
|
|
330
|
+
* 500 with no test of this file noticing. "Running" is `activeLoops`' word for it — registered,
|
|
331
|
+
* still a directory, `LOOP.md` present (a corrupt one included, so that it reaches the parse and
|
|
332
|
+
* fails there) — matched on the root the registry stores, which is the realpath. The preset is
|
|
333
|
+
* checked first: `activeLoops` may prune the registry, and a refused preset must touch nothing
|
|
334
|
+
* (`tests/unit/web_server.test.mjs` probes this route against the real machine's registry).
|
|
335
|
+
* Anything `setLoopPreset` still throws, a corrupt `LOOP.md` above all, is a 500.
|
|
593
336
|
*/
|
|
594
337
|
export function apiLoopsPresetMutate(state, body) {
|
|
595
338
|
const root = strOr(bget(body, 'root'));
|
|
596
339
|
const preset = strOr(bget(body, 'preset'));
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
} catch (
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
340
|
+
if (!Object.hasOwn(PRESETS, preset)) throw new NotFound(`unknown preset ${JSON.stringify(preset)}`);
|
|
341
|
+
let key;
|
|
342
|
+
try { key = realpathSync.native(root); } catch { key = path.resolve(root); }
|
|
343
|
+
const running = root && activeLoops().some((l) => l.status !== 'finished'
|
|
344
|
+
&& normcase(l.root) === normcase(key));
|
|
345
|
+
if (!running) throw new NotFound('unknown loop');
|
|
346
|
+
return { ok: true, loop: setLoopPreset(root, preset) };
|
|
603
347
|
}
|
|
604
348
|
|
|
605
349
|
// ---- MCP ----------------------------------------------------------------------------------
|
|
@@ -670,7 +414,7 @@ export function apiMcpToggle(state, body) {
|
|
|
670
414
|
return { ok: false, error: 'config holds comments — edit it by hand to keep them' };
|
|
671
415
|
}
|
|
672
416
|
let cfg;
|
|
673
|
-
if (
|
|
417
|
+
if (CLAUDE_STYLE.includes(host)) {
|
|
674
418
|
if (isFile(p)) {
|
|
675
419
|
let parsed;
|
|
676
420
|
try {
|
|
@@ -717,15 +461,7 @@ export function apiMcpToggle(state, body) {
|
|
|
717
461
|
* pair impersonate another in a lookup whose whole job is to be an allowlist.
|
|
718
462
|
*/
|
|
719
463
|
export function apiSelectView(state, body) {
|
|
720
|
-
const
|
|
721
|
-
for (const [host, scope, root] of installTargets()) {
|
|
722
|
-
known.set(JSON.stringify([host, String(root)]), [host, scope, root]);
|
|
723
|
-
}
|
|
724
|
-
const hit = known.get(JSON.stringify(
|
|
725
|
-
[strOr(bget(body, 'host')), strOr(bget(body, 'path'))],
|
|
726
|
-
));
|
|
727
|
-
if (hit === undefined) throw new NotFound('unknown install (host, path)');
|
|
728
|
-
const [host, scope, root] = hit;
|
|
464
|
+
const [host, scope, root] = detectedInstall(body);
|
|
729
465
|
state.selectView(viewCfg(host, scope, root), root);
|
|
730
466
|
return { ok: true, target: state.target, theme: state.theme, emit: state.emit };
|
|
731
467
|
}
|
|
@@ -811,6 +547,9 @@ export function apiRestore(state, files) {
|
|
|
811
547
|
// behind it — prompt and boundary disagreeing by a WRITE, the write-side twin of the
|
|
812
548
|
// read-side hole `diffCollect` had (and the reason the panel offered the file at all).
|
|
813
549
|
doctrines: doctrinesForBuild(target),
|
|
550
|
+
// The excluded rules for the same boundary reason: without them a restored AGENT.md
|
|
551
|
+
// re-states a rule whose hook gate the install deliberately left unwired.
|
|
552
|
+
excludeRules: excludedRulesOfDir(target),
|
|
814
553
|
}));
|
|
815
554
|
for (const raw of (isTruthy(files) ? files : [])) {
|
|
816
555
|
const rel = stripWhitespace(formatValue(raw).replace(/\\/g, '/')).replace(/^\/+/, '');
|
|
@@ -860,33 +599,15 @@ const EMIT_FOR = new Map([
|
|
|
860
599
|
* matters and what the argv head is.
|
|
861
600
|
*/
|
|
862
601
|
export function apiInstallCmd(state, body) {
|
|
863
|
-
const
|
|
864
|
-
for (const [host, scope, root] of installTargets()) {
|
|
865
|
-
known.set(JSON.stringify([host, String(root)]), [host, scope, root]);
|
|
866
|
-
}
|
|
867
|
-
const hit = known.get(JSON.stringify(
|
|
868
|
-
[strOr(bget(body, 'host')), strOr(bget(body, 'path'))],
|
|
869
|
-
));
|
|
870
|
-
if (hit === undefined) throw new NotFound('unknown install (host, path)');
|
|
871
|
-
const [host, scope, root] = hit;
|
|
602
|
+
const [host, scope, root] = detectedInstall(body);
|
|
872
603
|
if (installState(root, host, scope) === 'disabled') {
|
|
873
604
|
return { error: 'install is disabled — reactivate it before (re)building' };
|
|
874
605
|
}
|
|
875
606
|
const emit = EMIT_FOR.get(`${host} ${scope}`);
|
|
876
607
|
if (emit === undefined) return { error: `no install mode for ${host}:${scope}` };
|
|
877
|
-
const
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
const bfp = bget(body, 'footprint');
|
|
881
|
-
const fp = (bfp === 'lean' || bfp === 'full') ? bfp : footprintOfDir(root);
|
|
882
|
-
const bpos = bget(body, 'posture');
|
|
883
|
-
const pos = discoverNames('postures', 'peer').includes(bpos)
|
|
884
|
-
? bpos : (postureOfDir(root) || 'peer');
|
|
885
|
-
const bmode = bget(body, 'mode');
|
|
886
|
-
const mode = discoverNames('modes', 'direct').includes(bmode)
|
|
887
|
-
? bmode : (modeOfDir(root) || 'direct');
|
|
888
|
-
const btrust = bget(body, 'trust');
|
|
889
|
-
const trust = Object.keys(PRESETS).includes(btrust) ? btrust : (trustOfDir(root) || DEFAULT_PRESET);
|
|
608
|
+
const { theme, footprint: fp, posture: pos, mode, trust } = bodyAxes(body, {
|
|
609
|
+
theme: state.theme, footprint: footprintOfDir(root), posture: postureOfDir(root) || 'peer',
|
|
610
|
+
mode: modeOfDir(root) || 'direct', trust: trustOfDir(root, host) || DEFAULT_PRESET });
|
|
890
611
|
// Unspecified means "keep what this install already has", exactly as theme, footprint,
|
|
891
612
|
// posture and mode above do — a rebuild through the console is not a place to silently
|
|
892
613
|
// re-decide the constitution. ⚠ AND A CARRIER WITH NO `Active packs:` MARKER (a pre-2.3
|
|
@@ -919,15 +640,7 @@ export function apiInstallCmd(state, body) {
|
|
|
919
640
|
* in the web tree.
|
|
920
641
|
*/
|
|
921
642
|
export function apiInstallToggle(state, body) {
|
|
922
|
-
const
|
|
923
|
-
for (const [host, scope, root] of installTargets()) {
|
|
924
|
-
known.set(JSON.stringify([host, String(root)]), [host, scope, root]);
|
|
925
|
-
}
|
|
926
|
-
const hit = known.get(JSON.stringify(
|
|
927
|
-
[strOr(bget(body, 'host')), strOr(bget(body, 'path'))],
|
|
928
|
-
));
|
|
929
|
-
if (hit === undefined) throw new NotFound('unknown install (host, path)');
|
|
930
|
-
const [host, scope, root] = hit;
|
|
643
|
+
const [host, scope, root] = detectedInstall(body);
|
|
931
644
|
const action = bget(body, 'action');
|
|
932
645
|
let res;
|
|
933
646
|
if (action === 'deactivate') {
|
|
@@ -989,17 +702,10 @@ export function apiDeployCmd(state, body) {
|
|
|
989
702
|
if (cfgdirs.has(root)) {
|
|
990
703
|
return { error: "that's a host global config dir — use its existing row to build a global install" };
|
|
991
704
|
}
|
|
992
|
-
|
|
993
|
-
const
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
const fp = (bfp === 'lean' || bfp === 'full') ? bfp : 'full'; // a fresh deploy is full
|
|
997
|
-
const bpos = bget(body, 'posture');
|
|
998
|
-
const pos = discoverNames('postures', 'peer').includes(bpos) ? bpos : 'peer';
|
|
999
|
-
const bmode = bget(body, 'mode');
|
|
1000
|
-
const mode = discoverNames('modes', 'direct').includes(bmode) ? bmode : 'direct';
|
|
1001
|
-
const btrust = bget(body, 'trust');
|
|
1002
|
-
const trust = Object.keys(PRESETS).includes(btrust) ? btrust : DEFAULT_PRESET;
|
|
705
|
+
// A fresh deploy is full, at the default register, mode and trust.
|
|
706
|
+
const { theme, footprint: fp, posture: pos, mode, trust } = bodyAxes(body, {
|
|
707
|
+
theme: state.theme, footprint: 'full', posture: 'peer', mode: 'direct',
|
|
708
|
+
trust: DEFAULT_PRESET });
|
|
1003
709
|
// Same resolution as `apiInstallCmd` above, and for the same reason: the console's Deploy
|
|
1004
710
|
// form sends host/path/theme/footprint/posture/mode and NO pack selection, and nothing stops
|
|
1005
711
|
// it landing on a directory that already holds an install. Taking `bodyDoctrines` alone left
|
package/js/web/activity.mjs
CHANGED
|
@@ -40,7 +40,7 @@ import { readText, writeText, isDir, isFile } from '../lib/fs.mjs';
|
|
|
40
40
|
import { parseJson, isTruthy } from '../lib/json.mjs';
|
|
41
41
|
import { normcase } from '../lib/paths.mjs';
|
|
42
42
|
import { parseIntStrict, stripWhitespace } from '../lib/text.mjs';
|
|
43
|
-
import { NotFound, stemOf } from './
|
|
43
|
+
import { NotFound, stemOf } from './catalog.mjs';
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
46
|
* A session whose writer is gone, or that has not been touched in this long, is pruned.
|