geneseed 4.8.0 → 4.9.1
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 +34 -0
- package/SHIPPED.md +1 -1
- package/adapters/claude-code/README.md +3 -1
- package/adapters/opencode/plugins/geneseed-context.js +17 -1
- package/adapters/opencode/plugins/geneseed-guard.js +138 -32
- package/bin/geneseed-hook.mjs +9 -4
- package/docs/concepts/enforcement-table.md +2 -2
- package/docs/concepts/exclusions.md +18 -1
- package/docs/concepts/hooks.md +2 -2
- package/docs/reference/plugin-context.md +1 -1
- package/docs/reference/plugin-guard.md +1 -1
- package/docs/reference/troubleshoot-doctor.md +2 -1
- package/docs/web-ui.md +10 -8
- package/harness.config.json +1 -1
- package/js/build/args.mjs +3 -0
- package/js/build/driver.mjs +14 -6
- package/js/build/emit-claude.mjs +3 -3
- package/js/build/generate.mjs +16 -12
- package/js/build/render.mjs +7 -5
- package/js/build/source.mjs +19 -1
- package/js/cli-table.json +75 -0
- package/js/hosts/hooks-done.mjs +4 -0
- package/js/hosts/hooks-learn.mjs +3 -1
- package/js/hosts/hooks.mjs +14 -8
- package/js/hosts/installs.mjs +20 -11
- package/js/hosts/loss.mjs +124 -18
- package/js/hosts/settings.mjs +17 -8
- package/js/inspect/diff.mjs +4 -4
- package/js/inspect/doctor.mjs +4 -3
- package/js/inspect/status.mjs +4 -3
- package/js/inspect/validate.mjs +9 -4
- package/js/maintain/uninstall.mjs +7 -5
- package/js/maintain/update.mjs +1 -1
- package/js/web/actions.mjs +22 -17
- package/js/web/api.mjs +13 -11
- package/js/web/handler.mjs +1 -1
- package/js/web/routes.mjs +3 -1
- package/js/web/server.mjs +3 -1
- package/js/web/state.mjs +32 -8
- package/package.json +1 -1
- package/src/AGENT.md.tmpl +1 -1
- package/web/dist/assets/{Activity-B-XbKlzu.js → Activity-GQGAZHIE.js} +1 -1
- package/web/dist/assets/{ActivityDetail-8oq0fycD.js → ActivityDetail-DktWyxim.js} +1 -1
- package/web/dist/assets/Dashboard-CmSn6JfK.js +2 -0
- package/web/dist/assets/Docs-CsWYLnN1.js +5 -0
- package/web/dist/assets/FilterInput-zLFXoSt6.js +1 -0
- package/web/dist/assets/GreenhouseView-Dt_cIxAY.js +2 -0
- package/web/dist/assets/{Installs-DrqPaLDM.js → Installs-CWuUU4I3.js} +2 -2
- package/web/dist/assets/JournalView-ClbZj_YF.js +1 -0
- package/web/dist/assets/Laws-R5zZCmya.js +3 -0
- package/web/dist/assets/{Library-BCaqPf61.js → Library-u36V7oSD.js} +1 -1
- package/web/dist/assets/{Loops-Bx3HnlUA.js → Loops-BUBOeM0C.js} +2 -2
- package/web/dist/assets/{Markdown-BK38dn4x.js → Markdown-iHvBrpRN.js} +1 -1
- package/web/dist/assets/{OperatorHudView-ChQPM8JE.js → OperatorHudView-BJuCYsjf.js} +1 -1
- package/web/dist/assets/{Personal-DELm61DF.js → Personal-DWQuSAUz.js} +1 -1
- package/web/dist/assets/Tabs-GPQlocTw.js +1 -0
- package/web/dist/assets/{activity-D4w9t8fE.js → activity-DvPxoS4Y.js} +1 -1
- package/web/dist/assets/api-CiXlkbe0.js +1 -0
- package/web/dist/assets/drawable-CE-arUnF.js +1 -0
- package/web/dist/assets/{index-r2jRgf6l.js → index-3lnPT_1J.js} +2 -2
- package/web/dist/assets/{index-CHpydQJg.css → index-BCJz4_8C.css} +1 -1
- package/web/dist/assets/{react-Ci4wQD4a.js → react-BOF6yL40.js} +1 -1
- package/web/dist/assets/{sections-GalMS0Zv.js → sections-BET3c1WT.js} +1 -1
- package/web/dist/assets/{usePoll-BcywUo55.js → usePoll-NSTzLOF4.js} +1 -1
- package/web/dist/index.html +5 -5
- package/web/src/pages/Laws.jsx +52 -25
- package/web/dist/assets/Dashboard-C4EMz5mf.js +0 -2
- package/web/dist/assets/Docs-mq4XDQK_.js +0 -5
- package/web/dist/assets/FilterInput-Ikvi-pS9.js +0 -1
- package/web/dist/assets/GreenhouseView-D1WOtq3n.js +0 -2
- package/web/dist/assets/JournalView-BYpASU-L.js +0 -1
- package/web/dist/assets/Laws-A3PFwiPD.js +0 -3
- package/web/dist/assets/Tabs-DcMq5IvC.js +0 -1
- package/web/dist/assets/api-Bp5eW_6m.js +0 -1
- package/web/dist/assets/drawable-BI2_tF3o.js +0 -1
package/js/hosts/loss.mjs
CHANGED
|
@@ -18,14 +18,18 @@
|
|
|
18
18
|
* `branch -f`/`checkout -B`/`switch -C` moving a protected branch off commits nothing else
|
|
19
19
|
* holds — the same forms on a feature branch pass;
|
|
20
20
|
* - `stash drop`/`stash clear` while a stash exists; `reflog expire` and `gc --prune` always;
|
|
21
|
-
* - a file removal (`rm`, `rmdir`, `del`, `erase`, `Remove-Item`/`ri`/`rd`) of
|
|
22
|
-
* cannot bring back: untracked, ignored or modified files, a repository's own
|
|
23
|
-
* any path outside a git repository.
|
|
21
|
+
* - a file removal (`rm`, `rmdir`, `unlink`, `del`, `erase`, `Remove-Item`/`ri`/`rd`) of
|
|
22
|
+
* anything git cannot bring back: untracked, ignored or modified files, a repository's own
|
|
23
|
+
* `.git`, or any path outside a git repository.
|
|
24
24
|
*
|
|
25
25
|
* FAIL CLOSED. Anything this reader cannot settle — a glob or a `$variable` in a path it must
|
|
26
|
-
* name, a `cd` it cannot follow, an interpreter (`sh -c`, `xargs`, `eval`)
|
|
27
|
-
* a
|
|
28
|
-
*
|
|
26
|
+
* name, a `cd` it cannot follow, an interpreter (`sh -c`, `xargs`, `eval`, `find -delete`)
|
|
27
|
+
* carrying a removal, a removal that names no path (`… | Remove-Item` takes its paths from the
|
|
28
|
+
* pipe), a wrapper flag whose arity it does not know, ANY program it does not read (`setsid`,
|
|
29
|
+
* `ForEach-Object`, `case`) with a removal named unquoted after it, a git that fails, times out
|
|
30
|
+
* or is missing — asks, and the reason says it could not verify. A command substitution
|
|
31
|
+
* (`$(…)`, backticks) is judged as a command of its own. "Not proven safe" is an ask; only
|
|
32
|
+
* "proven safe" passes.
|
|
29
33
|
*
|
|
30
34
|
* THE ONE SPAWN IN THE GATE PATH, and why it is allowed. Whether a work tree is dirty, which
|
|
31
35
|
* files are untracked, and whether a branch's commits are reachable elsewhere are not readable
|
|
@@ -60,16 +64,31 @@ import { currentBranch, gitRootOf, protectedBranches, stashExists } from './gitr
|
|
|
60
64
|
* a superset of what the parse acts on.
|
|
61
65
|
*/
|
|
62
66
|
export const LOSS_CANDIDATE_RE =
|
|
63
|
-
/\bgit\b[^\n]*\b(?:reset|clean|branch|checkout|switch|restore|stash|reflog|gc|push)\b|(?:^|[\s;&|("'])(?:rm|rmdir|del|erase|remove-item|ri|rd)(?:\.exe)?(?=[\s;&|)"']|$)/im;
|
|
67
|
+
/\bgit\b[^\n]*\b(?:reset|clean|branch|checkout|switch|restore|stash|reflog|gc|push)\b|(?:^|[\s;&|("'`])(?:rm|rmdir|unlink|del|erase|remove-item|ri|rd)(?:\.exe)?(?=[\s;&|)"'`]|$)|(?:^|\s)-delete(?=\s|$)/im;
|
|
64
68
|
|
|
65
69
|
class Unverified extends Error {}
|
|
66
70
|
const unverified = (why) => { throw new Unverified(why); };
|
|
67
71
|
|
|
68
|
-
const RM_PROGS = new Set(['rm', 'rmdir', 'del', 'erase', 'remove-item', 'ri', 'rd']);
|
|
72
|
+
const RM_PROGS = new Set(['rm', 'rmdir', 'unlink', 'del', 'erase', 'remove-item', 'ri', 'rd']);
|
|
69
73
|
// cmd.exe's own removal verbs take `/s` `/q` switches, which are not paths.
|
|
70
74
|
const CMD_SWITCH_PROGS = new Set(['rmdir', 'del', 'erase', 'rd']);
|
|
71
75
|
const CD_PROGS = new Set(['cd', 'pushd', 'chdir', 'set-location', 'sl']);
|
|
72
|
-
|
|
76
|
+
// A wrapper runs the command after its own flags: `<short flags taking a value>|<short flags taking
|
|
77
|
+
// none>`. A flag in neither list (`env -S` runs a string, `env -C`/`sudo -D` change directory,
|
|
78
|
+
// `command -v` only looks up) is not guessed at — the segment asks if it names a removal.
|
|
79
|
+
const WRAPPERS = new Map([['sudo', 'ughpCUrtT|AbEHiknPSs'], ['doas', 'uC|ns'], ['command', '|p'],
|
|
80
|
+
['builtin', '|'], ['\\builtin', '|'], ['time', 'fo|pav'], ['nice', 'n|'], ['nohup', '|'],
|
|
81
|
+
['exec', 'a|cl'], ['env', 'u|i0v'], ['timeout', 'sk|v'], ['ionice', 'cn|t'], ['stdbuf', 'ioe|']]);
|
|
82
|
+
const WRAPPER_LONG = new Set(['--preserve-status', '--foreground', '--verbose', '--login', '--preserve-env',
|
|
83
|
+
'--non-interactive', '--ignore-environment', '--null']);
|
|
84
|
+
// Shell grammar that may stand before the program word (`(cd x && rm y)`, `if …; then rm y; fi`).
|
|
85
|
+
const STRUCTURE = new Set(['(', '((', '{', '!', 'if', 'then', 'elif', 'else', 'do', 'while', 'until']);
|
|
86
|
+
// The only programs this reader does not parse that may still name a removal word and pass: their
|
|
87
|
+
// `rm`/`del` is their own subcommand (a package, a container), or they never run their arguments
|
|
88
|
+
// (a substitution in one is judged on its own). Any other program over a removal word asks.
|
|
89
|
+
const INERT = new Set(['npm', 'pnpm', 'yarn', 'bun', 'cargo', 'brew', 'docker', 'podman', 'echo', 'printf',
|
|
90
|
+
'grep', 'egrep', 'fgrep', 'rg', 'findstr', 'select-string', 'which', 'where', 'type', 'man', 'get-command',
|
|
91
|
+
'get-help']);
|
|
73
92
|
// Programs that run ANOTHER command from their arguments or stdin: what they would remove is
|
|
74
93
|
// not on this command line in a form the parse can read, so a removal word inside one asks.
|
|
75
94
|
const INDIRECT = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh', 'fish', 'pwsh', 'powershell', 'cmd',
|
|
@@ -434,6 +453,8 @@ function rmAct(prog, args, dir) {
|
|
|
434
453
|
if (opts && CMD_SWITCH_PROGS.has(prog) && /^\/[a-zA-Z?]$/.test(a)) continue;
|
|
435
454
|
targets.push(a);
|
|
436
455
|
}
|
|
456
|
+
// `… | Remove-Item`, `ri -Force`: the paths come from the pipe, which this reader cannot see.
|
|
457
|
+
if (!targets.length) unverified(`\`${prog}\` names no path, so what it removes comes from elsewhere`);
|
|
437
458
|
const groups = new Map();
|
|
438
459
|
for (const t of targets) {
|
|
439
460
|
const shown = t.replace(OPAQUE, '');
|
|
@@ -475,6 +496,69 @@ function rmAct(prog, args, dir) {
|
|
|
475
496
|
/** The program word, stripped of its path and `.exe`, lowercased (PowerShell and cmd are case-blind). */
|
|
476
497
|
const progOf = (w) => w.split(/[\\/]/).pop().toLowerCase().replace(/\.exe$/, '');
|
|
477
498
|
|
|
499
|
+
/**
|
|
500
|
+
* A segment's RAW tokens with the shell grammar around its command dropped: leading `(`, `{`,
|
|
501
|
+
* `!`, `then`, `do`… (a `(` glued to the first word too) and a closing `)`/`}`. Without it
|
|
502
|
+
* `(git reset --hard)` has `(git` for its program and `--hard)` for its flag. `rootScan`
|
|
503
|
+
* shares it, so `(find / …)` is a scan.
|
|
504
|
+
*/
|
|
505
|
+
export function stripStructure(raw) {
|
|
506
|
+
const t = raw.slice();
|
|
507
|
+
while (t.length) {
|
|
508
|
+
if (STRUCTURE.has(t[0])) t.shift();
|
|
509
|
+
else if (/^\(+./.test(t[0])) t[0] = t[0].replace(/^\(+/, '');
|
|
510
|
+
else break;
|
|
511
|
+
}
|
|
512
|
+
while (t.length && (t[t.length - 1] === '}' || /^\)+$/.test(t[t.length - 1]))) t.pop();
|
|
513
|
+
if (t.length) t[t.length - 1] = t[t.length - 1].replace(/\)+$/, '');
|
|
514
|
+
return t;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* The bodies of the command substitutions in `text` (`$(…)`, backticks, `<(…)`, `>(…)`) —
|
|
519
|
+
* single quotes hide them, double quotes do not. An unclosed one runs to the end of the text:
|
|
520
|
+
* the splitter already cut an unquoted `$(a; b)` at its `;`.
|
|
521
|
+
*/
|
|
522
|
+
function substitutions(text) {
|
|
523
|
+
const out = [];
|
|
524
|
+
let sq = false; let dq = false;
|
|
525
|
+
for (let i = 0; i < text.length; i++) {
|
|
526
|
+
const c = text[i];
|
|
527
|
+
if (sq) { if (c === "'") sq = false; continue; }
|
|
528
|
+
if (c === "'" && !dq) { sq = true; continue; }
|
|
529
|
+
if (c === '"') { dq = !dq; continue; }
|
|
530
|
+
if (c === '`') {
|
|
531
|
+
const end = text.indexOf('`', i + 1);
|
|
532
|
+
out.push(text.slice(i + 1, end < 0 ? text.length : end));
|
|
533
|
+
i = end < 0 ? text.length : end;
|
|
534
|
+
} else if ('$<>'.includes(c) && text[i + 1] === '(') {
|
|
535
|
+
let depth = 1; let j = i + 2;
|
|
536
|
+
for (; j < text.length && depth; j++) depth += text[j] === '(' ? 1 : text[j] === ')' ? -1 : 0;
|
|
537
|
+
out.push(text.slice(i + 2, depth ? text.length : j - 1));
|
|
538
|
+
i = j - 1;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
return out;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** `w` past wrapper `name`'s own flags (and `timeout`'s duration), or null when a flag is unknown. */
|
|
545
|
+
function pastWrapper(name, w) {
|
|
546
|
+
const [takes, plain] = WRAPPERS.get(name).split('|');
|
|
547
|
+
let i = 0;
|
|
548
|
+
for (; i < w.length && w[i].length > 1 && w[i].startsWith('-'); i++) {
|
|
549
|
+
const a = w[i];
|
|
550
|
+
if (a === '--') { i += 1; break; }
|
|
551
|
+
if (a.startsWith('--')) { if (a.includes('=') || WRAPPER_LONG.has(a)) continue; return null; }
|
|
552
|
+
if (name === 'nice' && /^-\d+$/.test(a)) continue;
|
|
553
|
+
for (let j = 1; j < a.length; j++) {
|
|
554
|
+
if (takes.includes(a[j])) { if (j === a.length - 1) i += 1; break; } // the value: the rest, or the next word
|
|
555
|
+
if (!plain.includes(a[j])) return null;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
if (name === 'timeout' && /^\d/.test(w[i] || '')) i += 1;
|
|
559
|
+
return w.slice(i);
|
|
560
|
+
}
|
|
561
|
+
|
|
478
562
|
/**
|
|
479
563
|
* The verdict for a whole command, given its segments as RAW token arrays (quotes still on —
|
|
480
564
|
* `js/hosts/hooks.mjs` cuts and tokenises with the same lexer `rootScan` uses, so a quoted
|
|
@@ -485,19 +569,33 @@ const progOf = (w) => w.split(/[\\/]/).pop().toLowerCase().replace(/\.exe$/, '')
|
|
|
485
569
|
* each host's ceiling for a crashed gate (docs/adr/0007-gates-fail-closed-per-host-ceiling.md).
|
|
486
570
|
* `always: false` skips the two acts that lose data in every state (`reflog expire`, `gc --prune`)
|
|
487
571
|
* — the OpenCode guard passes it, because a static `permission.bash` glob already asks for those.
|
|
572
|
+
* `split` cuts a command substitution's body into segments the same way (both callers pass their
|
|
573
|
+
* splitter); without it, a substitution that names a removal asks.
|
|
574
|
+
* @param {string[][]} segments
|
|
575
|
+
* @param {string} cwd
|
|
576
|
+
* @param {{ always?: boolean, split?: (text: string) => string[][] }} [options]
|
|
488
577
|
*/
|
|
489
|
-
export function lossReason(segments, cwd, { always = true } = {}) {
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
578
|
+
export function lossReason(segments, cwd, { always = true, split } = {}) {
|
|
579
|
+
/** The first loss among `segs`, run from `start`; a substitution body is judged as a command. */
|
|
580
|
+
const judge = (segs, start, depth = 0) => {
|
|
581
|
+
let dir = start;
|
|
582
|
+
for (const raw of segs.map(stripStructure)) {
|
|
583
|
+
const text = raw.join(' ');
|
|
584
|
+
const named = LOSS_CANDIDATE_RE.test(text);
|
|
585
|
+
for (const body of named ? substitutions(text) : []) {
|
|
586
|
+
if (!LOSS_CANDIDATE_RE.test(body)) continue;
|
|
587
|
+
// Bounded: `$($($(…` nested past any real command would rescan the text once per level.
|
|
588
|
+
if (!split || depth >= 4) unverified('a command substitution names a removal');
|
|
589
|
+
const v = judge(split(body), dir, depth + 1);
|
|
590
|
+
if (v) return v;
|
|
591
|
+
}
|
|
493
592
|
let w = words(raw);
|
|
494
|
-
while (w.length && (/^\w+=/.test(w[0]) || WRAPPERS.has(progOf(w[0])))) {
|
|
593
|
+
while (w && w.length && (/^\w+=/.test(w[0]) || WRAPPERS.has(progOf(w[0])))) {
|
|
495
594
|
const wrapper = progOf(w[0]);
|
|
496
|
-
w = w.slice(1);
|
|
497
|
-
|
|
498
|
-
if (wrapper !== 'command' && wrapper !== 'exec') while (w.length && w[0].startsWith('-')) w = w.slice(1);
|
|
595
|
+
w = /^\w+=/.test(w[0]) ? w.slice(1) : pastWrapper(wrapper, w.slice(1));
|
|
596
|
+
if (!w && named) unverified(`\`${wrapper}\` takes a flag the gate does not know`);
|
|
499
597
|
}
|
|
500
|
-
if (!w.length) continue;
|
|
598
|
+
if (!w || !w.length) continue;
|
|
501
599
|
const prog = progOf(w[0]);
|
|
502
600
|
const args = w.slice(1);
|
|
503
601
|
if (CD_PROGS.has(prog)) {
|
|
@@ -510,10 +608,18 @@ export function lossReason(segments, cwd, { always = true } = {}) {
|
|
|
510
608
|
else if (RM_PROGS.has(prog)) verdict = rmAct(prog, args, dir);
|
|
511
609
|
else if (INDIRECT.has(prog) && LOSS_CANDIDATE_RE.test(args.join(' '))) {
|
|
512
610
|
unverified(`\`${prog}\` runs a command the gate cannot read`);
|
|
611
|
+
} else if (named && !INERT.has(prog)
|
|
612
|
+
&& LOSS_CANDIDATE_RE.test(raw.map((t) => t.replace(/"[^"]*"?|'[^']*'?/g, ' ')).join(' '))) {
|
|
613
|
+
// A program this reader does not know (a wrapper, a loop, shell syntax) over an unquoted
|
|
614
|
+
// removal word: it may run it, so it is not proven safe. A quoted word is data.
|
|
615
|
+
unverified(`\`${w[0].replace(OPAQUE, '')}\` is not a program the gate reads, and a removal follows it`);
|
|
513
616
|
}
|
|
514
617
|
if (verdict) return verdict;
|
|
515
618
|
}
|
|
516
619
|
return null;
|
|
620
|
+
};
|
|
621
|
+
try {
|
|
622
|
+
return judge(segments, cwd);
|
|
517
623
|
} catch (e) {
|
|
518
624
|
if (!(e instanceof Unverified)) throw e;
|
|
519
625
|
return `the gate could not verify what this command would lose (${e.message})`;
|
package/js/hosts/settings.mjs
CHANGED
|
@@ -726,7 +726,7 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null, excluded = [],
|
|
|
726
726
|
* remove. `default` here means unknown, and unknown keeps the gate (see `processPackOn`).
|
|
727
727
|
*/
|
|
728
728
|
export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [], host = 'claude',
|
|
729
|
-
deletionGate = true) {
|
|
729
|
+
deletionGate = true, { secretsGate = true, doneGate = true } = {}) {
|
|
730
730
|
const run = hookPrefix(hookOpts);
|
|
731
731
|
const mem = `--memory "${path.join(cfg, 'memory')}"`;
|
|
732
732
|
// --root carries the install's own dir so a GLOBAL hook can stand down when a live, wired
|
|
@@ -760,20 +760,25 @@ export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [],
|
|
|
760
760
|
// `--no-deletion-gate` only when the install switched Law IV's gate off, so an armed
|
|
761
761
|
// install's command stays byte-identical (and an older shim never sees the flag).
|
|
762
762
|
const dg = deletionGate === false ? ' --no-deletion-gate' : '';
|
|
763
|
+
// Law I's secret scan and Law III's done gate, under the same contract (user decision
|
|
764
|
+
// 2026-10-18): each flag only when that gate is off.
|
|
765
|
+
const sg = secretsGate === false ? ' --no-secrets-gate' : '';
|
|
766
|
+
const dn = doneGate === false ? ' --no-done-gate' : '';
|
|
763
767
|
return {
|
|
764
768
|
SessionStart: [{ hooks: [{ type: 'command', command: `${run} context --root "${cfg}"${b} || exit 0` }] }],
|
|
765
769
|
// One group, both gates: process 5's consent is a stderr line here (Bob has no ask
|
|
766
770
|
// tier), so the pack toggle that drops Claude's git-gate group has nothing to drop —
|
|
767
|
-
// `tool-gate` only ever EXITS 2 for Laws I and IV (
|
|
771
|
+
// `tool-gate` only ever EXITS 2 for Laws I and IV (unless built `--no-secrets-gate` /
|
|
772
|
+
// `--no-deletion-gate`), and for a
|
|
768
773
|
// write under a project's own `.geneseed/protected-checks.txt` — the project opted in —
|
|
769
774
|
// and for a recursive scan rooted at a whole filesystem (ops 2, the root-scan check).
|
|
770
|
-
PreToolUse: [{ hooks: [{ type: 'command', command: `${run} tool-gate --root "${cfg}"${b}${dg}` }] }],
|
|
775
|
+
PreToolUse: [{ hooks: [{ type: 'command', command: `${run} tool-gate --root "${cfg}"${b}${sg}${dg}` }] }],
|
|
771
776
|
// Bob's Stop payload never carries `transcript_path` (I2) — `learn` reads `--host bob`
|
|
772
777
|
// and returns immediately rather than wasting a model call on the bare envelope. KEPT
|
|
773
778
|
// rather than DROPPED: the stand-down costs nothing (no transcript read, no spawn), and
|
|
774
779
|
// if Bob ever starts sending one, learning on Bob starts working with no emit change —
|
|
775
780
|
// dropping the group would need a second one re-added later for the same reason.
|
|
776
|
-
Stop: [{ hooks: [{ type: 'command', command: `${run} learn ${mem}${b} || exit 0` }] }],
|
|
781
|
+
Stop: [{ hooks: [{ type: 'command', command: `${run} learn ${mem}${b}${dn} || exit 0` }] }],
|
|
777
782
|
};
|
|
778
783
|
}
|
|
779
784
|
// OpenClaude takes Claude's whole group set and verdicts unchanged. `context` needs the host
|
|
@@ -786,8 +791,11 @@ export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [],
|
|
|
786
791
|
const context = `${run} context --root "${cfg}"${h} || exit 0`;
|
|
787
792
|
const gate = `${run} git-gate --root "${cfg}"${h}${consentRuleOn(doctrines, excluded) ? '' : ' --no-consent'}`
|
|
788
793
|
+ (deletionGate === false ? ' --no-deletion-gate' : '');
|
|
789
|
-
|
|
790
|
-
|
|
794
|
+
// `--no-secrets-gate` skips only rule-gate's credential scan (rigor 5 and process 1 still
|
|
795
|
+
// run); `--no-done-gate` only learn's done check, never the distillation. One `learn` string
|
|
796
|
+
// feeds Stop, SubagentStop and PreCompact, and the flag sits before `|| exit 0`.
|
|
797
|
+
const ruleGate = `${run} rule-gate --root "${cfg}"${secretsGate === false ? ' --no-secrets-gate' : ''}`;
|
|
798
|
+
const learn = `${run} learn ${mem}${h}${doneGate === false ? ' --no-done-gate' : ''} || exit 0`;
|
|
791
799
|
// Claude Code only (hooks.md, v2.1.295+; Task 1): a `command` hook that can't start, times
|
|
792
800
|
// out, or exits anything but 0 or 2 — even one whose stdout happens to be valid JSON — now
|
|
793
801
|
// BLOCKS the tool call instead of sliding through as the non-blocking default. Only safe to
|
|
@@ -924,14 +932,15 @@ function readSettingsForMerge(p, consequence) {
|
|
|
924
932
|
* global that never stood down for a project install.
|
|
925
933
|
*/
|
|
926
934
|
export function mergeClaudeSettings(p, priorHooks = null, hookOpts = {},
|
|
927
|
-
doctrines = null, excluded = [], host = 'claude', cfgDir = path.dirname(p), deletionGate = true
|
|
935
|
+
doctrines = null, excluded = [], host = 'claude', cfgDir = path.dirname(p), deletionGate = true,
|
|
936
|
+
gates = {}) {
|
|
928
937
|
const prior = (priorHooks || []).filter(isDict);
|
|
929
938
|
const loaded = readSettingsForMerge(p, 'Hooks were not wired.');
|
|
930
939
|
if (!loaded) return [p, prior];
|
|
931
940
|
const [config, hadComments] = loaded;
|
|
932
941
|
let hooks = get(config, 'hooks');
|
|
933
942
|
if (!isDict(hooks)) hooks = {};
|
|
934
|
-
const canonical = claudeHookGroups(cfgDir, hookOpts, doctrines, excluded, host, deletionGate);
|
|
943
|
+
const canonical = claudeHookGroups(cfgDir, hookOpts, doctrines, excluded, host, deletionGate, gates);
|
|
935
944
|
const canonFlat = [];
|
|
936
945
|
for (const [event, groups] of Object.entries(canonical)) {
|
|
937
946
|
for (const g of groups) canonFlat.push({ event, group: g });
|
package/js/inspect/diff.mjs
CHANGED
|
@@ -44,8 +44,8 @@ import {
|
|
|
44
44
|
GLOBAL_MANIFEST, VERSION_MARKER, expanduser, opencodeConfigDir, resolvePath,
|
|
45
45
|
} from '../hosts/hosts.mjs';
|
|
46
46
|
import {
|
|
47
|
-
EMIT_HOST_SCOPE, defaultTheme,
|
|
48
|
-
excludedSkillsOfDir,
|
|
47
|
+
EMIT_HOST_SCOPE, defaultTheme, doctrinesForBuild, excludedRulesOfDir,
|
|
48
|
+
excludedSkillsOfDir, gatesOfDir,
|
|
49
49
|
footprintOfDir, modeOfDir, postureOfDir,
|
|
50
50
|
trustOfDir,
|
|
51
51
|
readJsonMaybe, readMaybe, themeOfDir,
|
|
@@ -184,8 +184,8 @@ export function diffCollect({ target = null, theme = null, emit = null, footprin
|
|
|
184
184
|
excludeRules: excludedRulesOfDir(dir),
|
|
185
185
|
// The excluded skills, or every skill the install left out reads as deleted.
|
|
186
186
|
excludeSkills: excludedSkillsOfDir(dir),
|
|
187
|
-
//
|
|
188
|
-
|
|
187
|
+
// The gate switches, or a disarmed install reads its `<gate>: off` line as drift.
|
|
188
|
+
...gatesOfDir(dir),
|
|
189
189
|
}));
|
|
190
190
|
// The one place a render depends on WHERE it is installed: a global Bob emit writes each
|
|
191
191
|
// folder skill's absolute directory into its SKILL.md (see `writeNativeLayer`), so the
|
package/js/inspect/doctor.mjs
CHANGED
|
@@ -25,9 +25,10 @@
|
|
|
25
25
|
* that this tool never shells back to the generator — is asserted directly instead.
|
|
26
26
|
*
|
|
27
27
|
* WHAT `doctorCollect` DOES NOT REACH, MEASURED RATHER THAN ASSUMED. It takes `groups` and
|
|
28
|
-
* `cmdDoctor` does not pass it: that parameter is the WEB's, and `
|
|
29
|
-
* `js/web/
|
|
30
|
-
* keep the Doctor page's per-check cards a parameter rather
|
|
28
|
+
* `cmdDoctor` does not pass it: that parameter is the WEB's, and `runDoctor` in
|
|
29
|
+
* `js/web/state.mjs` (the cache `apiDoctor` serves) is its only caller. `ran` exists for it —
|
|
30
|
+
* four lines of accumulator that keep the Doctor page's per-check cards a parameter rather
|
|
31
|
+
* than a rewrite.
|
|
31
32
|
*/
|
|
32
33
|
import path from 'node:path';
|
|
33
34
|
import { emitGlobalInto, emitProjectInto, main as driverMain } from '../build/driver.mjs';
|
package/js/inspect/status.mjs
CHANGED
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
59
59
|
import path from 'node:path';
|
|
60
60
|
|
|
61
|
-
import { THEMES, ROOT, makeCfg } from '../build/source.mjs';
|
|
61
|
+
import { GATE_SWITCHES, THEMES, ROOT, makeCfg } from '../build/source.mjs';
|
|
62
62
|
import { tuiInventory } from './inventory.mjs';
|
|
63
63
|
import { readVersion, sourceFingerprint } from '../build/version.mjs';
|
|
64
64
|
import {
|
|
@@ -422,8 +422,9 @@ export function statusLines(d, color = false) {
|
|
|
422
422
|
const excl = p.excludeRules?.length ? `excluding ${p.excludeRules.join(',')}` : 'no rules excluded';
|
|
423
423
|
rows.push([`${p.host}:${p.scope}`, [p.state, p.theme, p.footprint, `${p.posture}/${p.mode}/${p.trust}`,
|
|
424
424
|
packs, excl, ...(p.excludeSkills?.length ? [`skills off: ${p.excludeSkills.join(',')}`] : []),
|
|
425
|
-
//
|
|
426
|
-
...(p.
|
|
425
|
+
// Each elided when armed. The law's text still binds; only its tool-boundary gate is off.
|
|
426
|
+
...GATE_SWITCHES.filter((g) => p[g.key] === false)
|
|
427
|
+
.map((g) => `${g.marker.toLowerCase()}: off (Law ${g.law} text still binds)`)]
|
|
427
428
|
.join(` ${DOT} `) + where]);
|
|
428
429
|
}
|
|
429
430
|
|
package/js/inspect/validate.mjs
CHANGED
|
@@ -8,9 +8,10 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import { buildInto, emitGlobalInto, emitProjectInto } from '../build/driver.mjs';
|
|
11
|
+
import { GATE_SWITCHES } from '../build/source.mjs';
|
|
11
12
|
import { resolvePath } from '../hosts/hosts.mjs';
|
|
12
13
|
import {
|
|
13
|
-
|
|
14
|
+
doctrinesOfDir, excludedRulesOfDir, gatesOfDir, modeOfDir, postureOfDir,
|
|
14
15
|
} from '../hosts/installs.mjs';
|
|
15
16
|
import { validateIsVendored } from '../hosts/native.mjs';
|
|
16
17
|
import { printOut, printErr } from '../lib/fs.mjs';
|
|
@@ -33,7 +34,8 @@ export const VALIDATE_USAGE = [
|
|
|
33
34
|
'usage: geneseed validate [-h] [--theme THEME] [--emit EMIT] [--out OUT] [--root ROOT]',
|
|
34
35
|
' [--footprint {lean,full}] [--posture POSTURE] [--mode MODE]',
|
|
35
36
|
' [--trust TRUST] [--doctrines DOCTRINES] [--exclude-rules RULES]',
|
|
36
|
-
' [--exclude-skills SKILLS] [--no-
|
|
37
|
+
' [--exclude-skills SKILLS] [--no-secrets-gate] [--no-done-gate]',
|
|
38
|
+
' [--no-deletion-gate] [-v]',
|
|
37
39
|
'',
|
|
38
40
|
"Render and emit into a throwaway sandbox, run the doctor's checks, and write nothing real.",
|
|
39
41
|
"Takes the generator's render flags (see geneseed-build --help) except --sync-themes and",
|
|
@@ -129,6 +131,7 @@ export function cmdValidate(args) {
|
|
|
129
131
|
theme: args.theme, footprint: args.footprint, posture: args.posture, mode: args.mode,
|
|
130
132
|
trust: args.trust, doctrines: args.doctrines, excludeRules: args.excludeRules,
|
|
131
133
|
excludeSkills: args.excludeSkills, deletionGate: args.noDeletionGate ? false : null,
|
|
134
|
+
secretsGate: args.noSecretsGate ? false : null, doneGate: args.noDoneGate ? false : null,
|
|
132
135
|
};
|
|
133
136
|
let scanDirs;
|
|
134
137
|
try {
|
|
@@ -170,11 +173,13 @@ export function cmdValidate(args) {
|
|
|
170
173
|
const at = scanDirs[0];
|
|
171
174
|
const packs = doctrinesOfDir(at);
|
|
172
175
|
const excluded = excludedRulesOfDir(at);
|
|
176
|
+
const gates = gatesOfDir(at);
|
|
173
177
|
printOut(`[validate-only] rendered at posture=${postureOfDir(at) ?? '?'} `
|
|
174
178
|
+ `mode=${modeOfDir(at) ?? '?'} packs=${packs ? packs.join(',') || 'none' : '?'} `
|
|
175
179
|
+ `excluded=${excluded.map((id) => id.replace('.', ' ')).join(', ') || 'none'}`
|
|
176
|
-
//
|
|
177
|
-
+ `${
|
|
180
|
+
// Each elided when armed, so an armed dry run prints the line it always did.
|
|
181
|
+
+ `${GATE_SWITCHES.filter((g) => gates[g.key] === false)
|
|
182
|
+
.map((g) => ` ${g.marker.toLowerCase().replace(' ', '-')}=off`).join('')}\n`);
|
|
178
183
|
printOut(`[validate-only] would write ${written.length} file(s) under ${args.out}`
|
|
179
184
|
+ (args.root ? ` (root ${args.root})` : '')
|
|
180
185
|
+ ' — nothing was actually written (sandboxed).\n');
|
|
@@ -51,7 +51,7 @@ import { hookRunnerEntry } from '../hosts/shim.mjs';
|
|
|
51
51
|
import { confirm } from '../lib/prompt.mjs';
|
|
52
52
|
import {
|
|
53
53
|
claudeCfg, claudeReadManifest, doctrinesOfDir, EMIT_HOST_SCOPE, emitHostScopeOf,
|
|
54
|
-
|
|
54
|
+
excludedRulesOfDir, gatesOfDir, installKind, installState,
|
|
55
55
|
registeredTargets,
|
|
56
56
|
} from '../hosts/installs.mjs';
|
|
57
57
|
import {
|
|
@@ -1173,11 +1173,13 @@ function remergeClaudeHooks(cfg, root = cfg, host = 'claude') {
|
|
|
1173
1173
|
const excluded = excludedRulesOfDir(root, host).length ? excludedRulesOfDir(root, host)
|
|
1174
1174
|
: excludedRulesOfDir(cfg, host);
|
|
1175
1175
|
// `host` reaches the group builder: a Bob reactivate must re-wire Gemini-named groups.
|
|
1176
|
-
//
|
|
1177
|
-
// reactivate never disarms an install that did not ask for it.
|
|
1178
|
-
const
|
|
1176
|
+
// The gate switches, the same two-carrier read: a gate is off only when a carrier says so,
|
|
1177
|
+
// so a reactivate never disarms an install that did not ask for it.
|
|
1178
|
+
const [atRoot, atCfg] = [gatesOfDir(root, host), gatesOfDir(cfg, host)];
|
|
1179
|
+
const gates = Object.fromEntries(Object.keys(atRoot).map((k) => [k, atRoot[k] && atCfg[k]]));
|
|
1179
1180
|
const [, claims] = mergeClaudeSettings(settingsFile(cfg, managed),
|
|
1180
|
-
managed.settings_hooks ?? null, hookRunnerEntry(), doctrines, excluded, host, cfg,
|
|
1181
|
+
managed.settings_hooks ?? null, hookRunnerEntry(), doctrines, excluded, host, cfg,
|
|
1182
|
+
gates.deletionGate, gates);
|
|
1181
1183
|
// `and data` — a manifest that did not parse is not one to write back.
|
|
1182
1184
|
if (!deepEquals(claims, managed.settings_hooks ?? null) && Object.keys(data).length > 0) {
|
|
1183
1185
|
managed.settings_hooks = claims;
|
package/js/maintain/update.mjs
CHANGED
|
@@ -632,7 +632,7 @@ export async function installAxes(dir, { installProfile, setupBuildArgs }, notif
|
|
|
632
632
|
if (legacyProcessCarrierOfDir(String(dir), 'opencode')) return null;
|
|
633
633
|
const p = installProfile('opencode', 'global', String(dir), notify);
|
|
634
634
|
return setupBuildArgs('', 'files', null, null, p.footprint, p.posture, p.mode, p.doctrines,
|
|
635
|
-
undefined, p.excludeRules, p.trust, p.excludeSkills, p.deletionGate).slice(4);
|
|
635
|
+
undefined, p.excludeRules, p.trust, p.excludeSkills, p.deletionGate, p).slice(4);
|
|
636
636
|
}
|
|
637
637
|
|
|
638
638
|
/**
|
package/js/web/actions.mjs
CHANGED
|
@@ -46,8 +46,8 @@ import {
|
|
|
46
46
|
opencodeConfigDir, resolvePath,
|
|
47
47
|
} from '../hosts/hosts.mjs';
|
|
48
48
|
import {
|
|
49
|
-
EMIT_HOST_SCOPE,
|
|
50
|
-
footprintOfDir,
|
|
49
|
+
EMIT_HOST_SCOPE, doctrinesForBuild, excludedRulesOfDir, excludedSkillsOfDir,
|
|
50
|
+
footprintOfDir, gatesOfDir,
|
|
51
51
|
installState, installTargets, legacyProcessCarrierOfDir, modeOfDir, postureOfDir, trustOfDir,
|
|
52
52
|
} from '../hosts/installs.mjs';
|
|
53
53
|
import {
|
|
@@ -131,13 +131,17 @@ function bodyExcludeRules(body) {
|
|
|
131
131
|
}
|
|
132
132
|
|
|
133
133
|
/**
|
|
134
|
-
*
|
|
135
|
-
* else — a string `"false"`, a number,
|
|
136
|
-
* own answer, so a malformed
|
|
134
|
+
* The gate switches across the trust boundary (`deletionGate`, `secretsGate`, `doneGate`), each
|
|
135
|
+
* on its own: only a real JSON boolean counts. Anything else — a string `"false"`, a number,
|
|
136
|
+
* absence — falls back to the install's own answer for THAT gate, so a malformed or partial body
|
|
137
|
+
* can never disarm (or re-arm) a gate it did not name.
|
|
137
138
|
*/
|
|
138
|
-
function
|
|
139
|
-
const
|
|
140
|
-
return
|
|
139
|
+
function bodyGates(body, root, host) {
|
|
140
|
+
const own = gatesOfDir(root, host);
|
|
141
|
+
return Object.fromEntries(Object.entries(own).map(([k, v]) => {
|
|
142
|
+
const said = bget(body, k);
|
|
143
|
+
return [k, typeof said === 'boolean' ? said : v];
|
|
144
|
+
}));
|
|
141
145
|
}
|
|
142
146
|
|
|
143
147
|
/**
|
|
@@ -303,7 +307,8 @@ export function apiMemoryDelete(state, name) {
|
|
|
303
307
|
// CASE-FOLDED: this endpoint's own compared `MEMORY` exactly, so on Windows and macOS `memory`
|
|
304
308
|
// resolved to MEMORY.md, passed, and deleted the index the agent reads at session start.
|
|
305
309
|
if (memoryDelete(d, name) === null) throw new NotFound(name);
|
|
306
|
-
|
|
310
|
+
// The doctor reads nothing in the memory store, so its cached verdict survives (`refresh`).
|
|
311
|
+
state.refresh({ doctor: false });
|
|
307
312
|
return { archived: name };
|
|
308
313
|
}
|
|
309
314
|
|
|
@@ -501,8 +506,8 @@ export function apiRestore(state, files) {
|
|
|
501
506
|
// re-states a rule whose hook gate the install deliberately left unwired.
|
|
502
507
|
excludeRules: excludedRulesOfDir(target),
|
|
503
508
|
excludeSkills: excludedSkillsOfDir(target),
|
|
504
|
-
//
|
|
505
|
-
|
|
509
|
+
// The gate switches: a restored AGENT.md must keep the install's `<gate>: off` lines.
|
|
510
|
+
...gatesOfDir(target),
|
|
506
511
|
}));
|
|
507
512
|
for (const raw of (isTruthy(files) ? files : [])) {
|
|
508
513
|
const rel = stripWhitespace(formatValue(raw).replace(/\\/g, '/')).replace(/^\/+/, '');
|
|
@@ -585,12 +590,12 @@ export function apiInstallCmd(state, body) {
|
|
|
585
590
|
// the silence a pre-2.3 carrier gives on packs. Unspecified therefore keeps the install's
|
|
586
591
|
// own answer, and a pre-marker install answers the empty list — which is what it has.
|
|
587
592
|
const excludeRules = bodyExcludeRules(body) ?? excludedRulesOfDir(root, host);
|
|
588
|
-
//
|
|
589
|
-
// the install's own answer — so a pack Apply
|
|
590
|
-
const
|
|
593
|
+
// The gate switches (the Constitution page's toggles): a strict boolean per gate in the body,
|
|
594
|
+
// else the install's own answer — so a pack Apply keeps every switch as it is.
|
|
595
|
+
const gates = bodyGates(body, root, host);
|
|
591
596
|
const out = scope === 'global' ? null : String(root);
|
|
592
597
|
const argv = setupBuildArgs(theme || 'neutral', emit, out, out, fp, pos, mode, doctrines,
|
|
593
|
-
PACK_ORDER, excludeRules, trust, excludedSkillsOfDir(root, host), deletionGate);
|
|
598
|
+
PACK_ORDER, excludeRules, trust, excludedSkillsOfDir(root, host), gates.deletionGate, gates);
|
|
594
599
|
return { cmd: [process.execPath, path.join(ROOT, 'bin', 'build-driver.mjs'), ...argv] };
|
|
595
600
|
}
|
|
596
601
|
|
|
@@ -689,9 +694,9 @@ export function apiDeployCmd(state, body) {
|
|
|
689
694
|
// and `PreToolUse::Bash` went with them. `doctrinesForBuild` resolves unknown to ALL packs.
|
|
690
695
|
const doctrines = bodyDoctrines(body) ?? doctrinesForBuild(root, host);
|
|
691
696
|
const excludeRules = bodyExcludeRules(body) ?? excludedRulesOfDir(root, host);
|
|
692
|
-
const
|
|
697
|
+
const gates = bodyGates(body, root, host);
|
|
693
698
|
// project-scope emit name == host name (opencode / claude / bob / openclaude)
|
|
694
699
|
const argv = setupBuildArgs(theme || 'neutral', host, root, root, fp, pos, mode, doctrines,
|
|
695
|
-
PACK_ORDER, excludeRules, trust, excludedSkillsOfDir(root, host), deletionGate);
|
|
700
|
+
PACK_ORDER, excludeRules, trust, excludedSkillsOfDir(root, host), gates.deletionGate, gates);
|
|
696
701
|
return { cmd: [process.execPath, path.join(ROOT, 'bin', 'build-driver.mjs'), ...argv] };
|
|
697
702
|
}
|
package/js/web/api.mjs
CHANGED
|
@@ -16,11 +16,10 @@ import { readVersion, sourceFingerprint } from '../build/version.mjs';
|
|
|
16
16
|
import { sourceReleaseVersion } from '../hosts/opencode.mjs';
|
|
17
17
|
import { alwaysOnCost } from '../inspect/cost.mjs';
|
|
18
18
|
import { diffCollect } from '../inspect/diff.mjs';
|
|
19
|
-
import { doctorCollect } from '../inspect/doctor.mjs';
|
|
20
19
|
import { excludesSnapshot } from '../inspect/excludes.mjs';
|
|
21
20
|
import { CLAUDE_STYLE, HOSTS, VERSION_MARKER, resolvePath } from '../hosts/hosts.mjs';
|
|
22
21
|
import {
|
|
23
|
-
|
|
22
|
+
footprintOfDir, gatesOfDir, installState, installTargets, modeOfDir, trustOfDir,
|
|
24
23
|
postureOfDir, readJsonMaybe, themeOfDir,
|
|
25
24
|
} from '../hosts/installs.mjs';
|
|
26
25
|
import { PRESETS } from '../loop/score.mjs';
|
|
@@ -79,13 +78,15 @@ export function apiSetup(state) {
|
|
|
79
78
|
};
|
|
80
79
|
}
|
|
81
80
|
|
|
82
|
-
/**
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
81
|
+
/**
|
|
82
|
+
* The same engine as the `doctor` verb, grouped per check — served from the state's cached run
|
|
83
|
+
* (a lens visit or a page mount no longer pays ~3.6 s), and re-run only when `fresh` is asked
|
|
84
|
+
* for: the Doctor page's Re-run button, as `GET /api/doctor?fresh=1`.
|
|
85
|
+
*/
|
|
86
|
+
export function apiDoctor(state, fresh = false) {
|
|
87
|
+
if (fresh) state.runDoctor();
|
|
88
|
+
const { ok, problems, checked_at } = state.doctor;
|
|
89
|
+
return { ...state._doctorRun, ok, problems, checked_at };
|
|
89
90
|
}
|
|
90
91
|
|
|
91
92
|
/**
|
|
@@ -247,9 +248,10 @@ export function apiOverview(state) {
|
|
|
247
248
|
for (const [host, scope, root] of installTargets()) {
|
|
248
249
|
try {
|
|
249
250
|
if (samePath(viewCfg(host, scope, root), state.target)) {
|
|
250
|
-
// `deletionGate`:
|
|
251
|
+
// `deletionGate`/`secretsGate`/`doneGate`: the gate switches, for the Constitution
|
|
252
|
+
// page's toggles on rows IV, I and III.
|
|
251
253
|
install = { host, scope, path: root, footprint: footprintOfDir(root),
|
|
252
|
-
|
|
254
|
+
...gatesOfDir(root, host) };
|
|
253
255
|
break;
|
|
254
256
|
}
|
|
255
257
|
} catch { /* an unreadable candidate just isn't a match */ }
|
package/js/web/handler.mjs
CHANGED
|
@@ -130,7 +130,7 @@ export function makeHandler(state, jm, token, dist, holder = null) {
|
|
|
130
130
|
|
|
131
131
|
function doGet(req, res, path, ae) {
|
|
132
132
|
const route = STATE_ROUTES[path];
|
|
133
|
-
if (route !== undefined) return sendJson(res, route(state), 200, ae);
|
|
133
|
+
if (route !== undefined) return sendJson(res, route(state, req.url), 200, ae);
|
|
134
134
|
// `pid` is what a restarting client waits on: the OLD server keeps answering this ping for
|
|
135
135
|
// a moment after `/api/restart` returns, and a reload onto it fetches the old token.
|
|
136
136
|
if (path === '/api/ping') {
|
package/js/web/routes.mjs
CHANGED
|
@@ -146,7 +146,9 @@ export const STATE_ROUTES = {
|
|
|
146
146
|
'/api/recent': apiRecent,
|
|
147
147
|
'/api/themes': apiThemes,
|
|
148
148
|
'/api/setup': apiSetup,
|
|
149
|
-
|
|
149
|
+
// The one route here that reads its query: `?fresh=1` re-runs instead of serving the cache.
|
|
150
|
+
'/api/doctor': (state, url) => apiDoctor(state,
|
|
151
|
+
new URL(url || '', 'http://x').searchParams.has('fresh')),
|
|
150
152
|
'/api/installs': apiInstalls,
|
|
151
153
|
'/api/fleet': apiFleet,
|
|
152
154
|
'/api/update': apiUpdate,
|
package/js/web/server.mjs
CHANGED
|
@@ -27,7 +27,7 @@ import { isFile, printOut } from '../lib/fs.mjs';
|
|
|
27
27
|
import { which } from '../lib/paths.mjs';
|
|
28
28
|
import { parseIntStrict } from '../lib/text.mjs';
|
|
29
29
|
import { promptLine } from '../lib/prompt.mjs';
|
|
30
|
-
import { webState } from './state.mjs';
|
|
30
|
+
import { warmDoctor, webState } from './state.mjs';
|
|
31
31
|
import { clearDaemon, codeStamp, openUrl, readDaemon, restartDaemon, startDaemon, statusDaemon, stopDaemon, writeDaemon } from './daemon.mjs';
|
|
32
32
|
import { makeHandler } from './handler.mjs';
|
|
33
33
|
import { JobManager } from './jobs.mjs';
|
|
@@ -165,6 +165,8 @@ export async function serve({ theme = null, port = 4747, openBrowser = true,
|
|
|
165
165
|
printOut(`[web] Geneseed UI on ${url} (theme: ${state.theme})\n`);
|
|
166
166
|
printOut(daemon ? '[web] daemon ready.\n' : '[web] Ctrl-C to stop.\n');
|
|
167
167
|
if (openBrowser) openUrl(url);
|
|
168
|
+
// After the record and the ready line, so `web start`'s wait loop already has its URL.
|
|
169
|
+
warmDoctor(state);
|
|
168
170
|
// Node kills on SIGINT by default, which would skip this message and the record
|
|
169
171
|
// cleanup in `srv.on('close', ...)` below — a foreground `--daemon-internal` server
|
|
170
172
|
// stopped with Ctrl-C would otherwise leave a record pointing at a dead port.
|