geneseed 4.9.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 +21 -0
- package/SHIPPED.md +1 -1
- package/adapters/opencode/plugins/geneseed-guard.js +121 -17
- package/docs/web-ui.md +3 -2
- package/harness.config.json +1 -1
- package/js/hosts/hooks.mjs +11 -7
- package/js/hosts/loss.mjs +124 -18
- package/js/inspect/doctor.mjs +4 -3
- package/js/web/actions.mjs +2 -1
- package/js/web/api.mjs +9 -8
- 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/web/dist/assets/{Activity-DZ3O_DsJ.js → Activity-GQGAZHIE.js} +1 -1
- package/web/dist/assets/{ActivityDetail-BQSWwoJ-.js → ActivityDetail-DktWyxim.js} +1 -1
- package/web/dist/assets/{Dashboard-COLskkOJ.js → Dashboard-CmSn6JfK.js} +2 -2
- package/web/dist/assets/{Docs-DUxizutI.js → Docs-CsWYLnN1.js} +1 -1
- package/web/dist/assets/{GreenhouseView-BSzVX_l7.js → GreenhouseView-Dt_cIxAY.js} +1 -1
- package/web/dist/assets/{Installs-Ck4YyEzs.js → Installs-CWuUU4I3.js} +2 -2
- package/web/dist/assets/{JournalView-BbbACsYK.js → JournalView-ClbZj_YF.js} +1 -1
- package/web/dist/assets/{Laws-BZ_M86r3.js → Laws-R5zZCmya.js} +1 -1
- package/web/dist/assets/{Library-D_4LbUg2.js → Library-u36V7oSD.js} +1 -1
- package/web/dist/assets/{Loops-Bzb2Y96W.js → Loops-BUBOeM0C.js} +1 -1
- package/web/dist/assets/{OperatorHudView-D-nzB6_r.js → OperatorHudView-BJuCYsjf.js} +1 -1
- package/web/dist/assets/{Personal-C66284wd.js → Personal-DWQuSAUz.js} +1 -1
- package/web/dist/assets/{Tabs-DkPfjG3H.js → Tabs-GPQlocTw.js} +1 -1
- package/web/dist/assets/api-CiXlkbe0.js +1 -0
- package/web/dist/assets/{drawable-DlrXDOJ7.js → drawable-CE-arUnF.js} +1 -1
- package/web/dist/assets/{index-DJorAjeH.js → index-3lnPT_1J.js} +2 -2
- package/web/dist/index.html +2 -2
- package/web/dist/assets/api-Bp5eW_6m.js +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,27 @@ label. For the capability ↔ spec map, see [SHIPPED.md](SHIPPED.md).
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [4.9.1] — 2026-10-18
|
|
12
|
+
|
|
13
|
+
Patch: Deletion Is Deliberate and the root-scan check no longer fail open behind shell structure; the console stops re-running the doctor on every change.
|
|
14
|
+
|
|
15
|
+
### Upgrade notes
|
|
16
|
+
- Run `geneseed rebuild-all` once after updating so installed hooks and OpenCode plugins pick up the fix.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
- **Deletion Is Deliberate and the root-scan check no longer fail open when shell structure hides the program word.** `true & git reset --hard`, `(git reset --hard)`, `if …; then git reset --hard; fi`, `{ rm x; }`, `echo $(rm x)`, `` echo `rm x` ``, `sudo -u root rm x`, `env -u FOO rm x`, `nice -n 5 rm x`, `timeout 5 rm x`, `true & find / …` and `(find / …)` passed where the plain form asks. The `reset --hard` forms had regressed in 4.7.0. Several changes close it, on Claude Code, OpenClaude and Bob (`git-gate`/`tool-gate`) and in the OpenCode guard:
|
|
20
|
+
- A lone `&` now separates commands. `2>&1`, `<&3`, `&>` and `|&` still do not.
|
|
21
|
+
- Leading `(`, `{`, `!`, `then`, `do` and `else` are stripped before the program word, and so are closing `)`/`}`.
|
|
22
|
+
- A command substitution is judged as a command of its own.
|
|
23
|
+
- Wrappers drop their value-taking flags. `timeout` is now a wrapper.
|
|
24
|
+
- A removal that names no path asks: `… | Remove-Item`, `ri -Force`.
|
|
25
|
+
- `find -delete` asks, and `unlink` counts as a removal.
|
|
26
|
+
- Behind all of these, a removal word written unquoted after a program the gate does not read asks ("could not verify"). That covers a wrapper it does not know (`setsid rm x`), a wrapper flag of unknown arity, `ForEach-Object { Remove-Item $_ }` and `case … rm …`. So does `ssh host rm x`. The exceptions are programs whose `rm` is their own subcommand (npm, pnpm, yarn, bun, cargo, brew, docker, podman) and programs that never run their arguments (echo, printf, the grep family, `which`, `type`, `man`, `Get-Command`, `Get-Help`).
|
|
27
|
+
- Proven-safe forms still pass: a clean tree, a tracked clean file, a quoted pattern, and a `$(cat <<'EOF' … EOF)` commit message.
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
- **The console stops re-running the doctor when nothing it checks has changed.** A memory delete no longer drops the cached verdict (~3.6 s saved on the next overview); a finished job, an install toggle and a view switch still do. The Doctor page, the Greenhouse and Operator HUD lenses now read the cached run (groups and themes included) instead of running every check on each visit; the page's *Re-run checks* button asks for a fresh run (`GET /api/doctor?fresh=1`). The server warms the cache right after it starts listening, so an overview requested a few seconds later answers in ~0.15 s rather than ~3.8 s.
|
|
31
|
+
|
|
11
32
|
## [4.9.0] — 2026-10-18
|
|
12
33
|
|
|
13
34
|
Minor: on/off switches for the Sealed Secrets and done gates, and the console calls the laws Dictates.
|
package/SHIPPED.md
CHANGED
|
@@ -20,7 +20,7 @@ historical references, not files distributed with the repo.
|
|
|
20
20
|
> **On version numbers.** [`harness.config.json`](harness.config.json)'s `version` is a
|
|
21
21
|
> human-readable label only. The canonical identity of an installed harness is the
|
|
22
22
|
> **source fingerprint** stamped in `.geneseed-version` (see `geneseed version`), not the
|
|
23
|
-
> string. Current label: **4.9.
|
|
23
|
+
> string. Current label: **4.9.1**.
|
|
24
24
|
|
|
25
25
|
## Capabilities
|
|
26
26
|
|
|
@@ -297,16 +297,31 @@ function stashExists(root) {
|
|
|
297
297
|
* a superset of what the parse acts on.
|
|
298
298
|
*/
|
|
299
299
|
const LOSS_CANDIDATE_RE =
|
|
300
|
-
/\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;
|
|
300
|
+
/\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;
|
|
301
301
|
|
|
302
302
|
class Unverified extends Error {}
|
|
303
303
|
const unverified = (why) => { throw new Unverified(why); };
|
|
304
304
|
|
|
305
|
-
const RM_PROGS = new Set(['rm', 'rmdir', 'del', 'erase', 'remove-item', 'ri', 'rd']);
|
|
305
|
+
const RM_PROGS = new Set(['rm', 'rmdir', 'unlink', 'del', 'erase', 'remove-item', 'ri', 'rd']);
|
|
306
306
|
// cmd.exe's own removal verbs take `/s` `/q` switches, which are not paths.
|
|
307
307
|
const CMD_SWITCH_PROGS = new Set(['rmdir', 'del', 'erase', 'rd']);
|
|
308
308
|
const CD_PROGS = new Set(['cd', 'pushd', 'chdir', 'set-location', 'sl']);
|
|
309
|
-
|
|
309
|
+
// A wrapper runs the command after its own flags: `<short flags taking a value>|<short flags taking
|
|
310
|
+
// none>`. A flag in neither list (`env -S` runs a string, `env -C`/`sudo -D` change directory,
|
|
311
|
+
// `command -v` only looks up) is not guessed at — the segment asks if it names a removal.
|
|
312
|
+
const WRAPPERS = new Map([['sudo', 'ughpCUrtT|AbEHiknPSs'], ['doas', 'uC|ns'], ['command', '|p'],
|
|
313
|
+
['builtin', '|'], ['\\builtin', '|'], ['time', 'fo|pav'], ['nice', 'n|'], ['nohup', '|'],
|
|
314
|
+
['exec', 'a|cl'], ['env', 'u|i0v'], ['timeout', 'sk|v'], ['ionice', 'cn|t'], ['stdbuf', 'ioe|']]);
|
|
315
|
+
const WRAPPER_LONG = new Set(['--preserve-status', '--foreground', '--verbose', '--login', '--preserve-env',
|
|
316
|
+
'--non-interactive', '--ignore-environment', '--null']);
|
|
317
|
+
// Shell grammar that may stand before the program word (`(cd x && rm y)`, `if …; then rm y; fi`).
|
|
318
|
+
const STRUCTURE = new Set(['(', '((', '{', '!', 'if', 'then', 'elif', 'else', 'do', 'while', 'until']);
|
|
319
|
+
// The only programs this reader does not parse that may still name a removal word and pass: their
|
|
320
|
+
// `rm`/`del` is their own subcommand (a package, a container), or they never run their arguments
|
|
321
|
+
// (a substitution in one is judged on its own). Any other program over a removal word asks.
|
|
322
|
+
const INERT = new Set(['npm', 'pnpm', 'yarn', 'bun', 'cargo', 'brew', 'docker', 'podman', 'echo', 'printf',
|
|
323
|
+
'grep', 'egrep', 'fgrep', 'rg', 'findstr', 'select-string', 'which', 'where', 'type', 'man', 'get-command',
|
|
324
|
+
'get-help']);
|
|
310
325
|
// Programs that run ANOTHER command from their arguments or stdin: what they would remove is
|
|
311
326
|
// not on this command line in a form the parse can read, so a removal word inside one asks.
|
|
312
327
|
const INDIRECT = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh', 'fish', 'pwsh', 'powershell', 'cmd',
|
|
@@ -671,6 +686,8 @@ function rmAct(prog, args, dir) {
|
|
|
671
686
|
if (opts && CMD_SWITCH_PROGS.has(prog) && /^\/[a-zA-Z?]$/.test(a)) continue;
|
|
672
687
|
targets.push(a);
|
|
673
688
|
}
|
|
689
|
+
// `… | Remove-Item`, `ri -Force`: the paths come from the pipe, which this reader cannot see.
|
|
690
|
+
if (!targets.length) unverified(`\`${prog}\` names no path, so what it removes comes from elsewhere`);
|
|
674
691
|
const groups = new Map();
|
|
675
692
|
for (const t of targets) {
|
|
676
693
|
const shown = t.replace(OPAQUE, '');
|
|
@@ -712,6 +729,69 @@ function rmAct(prog, args, dir) {
|
|
|
712
729
|
/** The program word, stripped of its path and `.exe`, lowercased (PowerShell and cmd are case-blind). */
|
|
713
730
|
const progOf = (w) => w.split(/[\\/]/).pop().toLowerCase().replace(/\.exe$/, '');
|
|
714
731
|
|
|
732
|
+
/**
|
|
733
|
+
* A segment's RAW tokens with the shell grammar around its command dropped: leading `(`, `{`,
|
|
734
|
+
* `!`, `then`, `do`… (a `(` glued to the first word too) and a closing `)`/`}`. Without it
|
|
735
|
+
* `(git reset --hard)` has `(git` for its program and `--hard)` for its flag. `rootScan`
|
|
736
|
+
* shares it, so `(find / …)` is a scan.
|
|
737
|
+
*/
|
|
738
|
+
function stripStructure(raw) {
|
|
739
|
+
const t = raw.slice();
|
|
740
|
+
while (t.length) {
|
|
741
|
+
if (STRUCTURE.has(t[0])) t.shift();
|
|
742
|
+
else if (/^\(+./.test(t[0])) t[0] = t[0].replace(/^\(+/, '');
|
|
743
|
+
else break;
|
|
744
|
+
}
|
|
745
|
+
while (t.length && (t[t.length - 1] === '}' || /^\)+$/.test(t[t.length - 1]))) t.pop();
|
|
746
|
+
if (t.length) t[t.length - 1] = t[t.length - 1].replace(/\)+$/, '');
|
|
747
|
+
return t;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* The bodies of the command substitutions in `text` (`$(…)`, backticks, `<(…)`, `>(…)`) —
|
|
752
|
+
* single quotes hide them, double quotes do not. An unclosed one runs to the end of the text:
|
|
753
|
+
* the splitter already cut an unquoted `$(a; b)` at its `;`.
|
|
754
|
+
*/
|
|
755
|
+
function substitutions(text) {
|
|
756
|
+
const out = [];
|
|
757
|
+
let sq = false; let dq = false;
|
|
758
|
+
for (let i = 0; i < text.length; i++) {
|
|
759
|
+
const c = text[i];
|
|
760
|
+
if (sq) { if (c === "'") sq = false; continue; }
|
|
761
|
+
if (c === "'" && !dq) { sq = true; continue; }
|
|
762
|
+
if (c === '"') { dq = !dq; continue; }
|
|
763
|
+
if (c === '`') {
|
|
764
|
+
const end = text.indexOf('`', i + 1);
|
|
765
|
+
out.push(text.slice(i + 1, end < 0 ? text.length : end));
|
|
766
|
+
i = end < 0 ? text.length : end;
|
|
767
|
+
} else if ('$<>'.includes(c) && text[i + 1] === '(') {
|
|
768
|
+
let depth = 1; let j = i + 2;
|
|
769
|
+
for (; j < text.length && depth; j++) depth += text[j] === '(' ? 1 : text[j] === ')' ? -1 : 0;
|
|
770
|
+
out.push(text.slice(i + 2, depth ? text.length : j - 1));
|
|
771
|
+
i = j - 1;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
return out;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/** `w` past wrapper `name`'s own flags (and `timeout`'s duration), or null when a flag is unknown. */
|
|
778
|
+
function pastWrapper(name, w) {
|
|
779
|
+
const [takes, plain] = WRAPPERS.get(name).split('|');
|
|
780
|
+
let i = 0;
|
|
781
|
+
for (; i < w.length && w[i].length > 1 && w[i].startsWith('-'); i++) {
|
|
782
|
+
const a = w[i];
|
|
783
|
+
if (a === '--') { i += 1; break; }
|
|
784
|
+
if (a.startsWith('--')) { if (a.includes('=') || WRAPPER_LONG.has(a)) continue; return null; }
|
|
785
|
+
if (name === 'nice' && /^-\d+$/.test(a)) continue;
|
|
786
|
+
for (let j = 1; j < a.length; j++) {
|
|
787
|
+
if (takes.includes(a[j])) { if (j === a.length - 1) i += 1; break; } // the value: the rest, or the next word
|
|
788
|
+
if (!plain.includes(a[j])) return null;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
if (name === 'timeout' && /^\d/.test(w[i] || '')) i += 1;
|
|
792
|
+
return w.slice(i);
|
|
793
|
+
}
|
|
794
|
+
|
|
715
795
|
/**
|
|
716
796
|
* The verdict for a whole command, given its segments as RAW token arrays (quotes still on —
|
|
717
797
|
* `js/hosts/hooks.mjs` cuts and tokenises with the same lexer `rootScan` uses, so a quoted
|
|
@@ -722,19 +802,33 @@ const progOf = (w) => w.split(/[\\/]/).pop().toLowerCase().replace(/\.exe$/, '')
|
|
|
722
802
|
* each host's ceiling for a crashed gate (docs/adr/0007-gates-fail-closed-per-host-ceiling.md).
|
|
723
803
|
* `always: false` skips the two acts that lose data in every state (`reflog expire`, `gc --prune`)
|
|
724
804
|
* — the OpenCode guard passes it, because a static `permission.bash` glob already asks for those.
|
|
805
|
+
* `split` cuts a command substitution's body into segments the same way (both callers pass their
|
|
806
|
+
* splitter); without it, a substitution that names a removal asks.
|
|
807
|
+
* @param {string[][]} segments
|
|
808
|
+
* @param {string} cwd
|
|
809
|
+
* @param {{ always?: boolean, split?: (text: string) => string[][] }} [options]
|
|
725
810
|
*/
|
|
726
|
-
function lossReason(segments, cwd, { always = true } = {}) {
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
811
|
+
function lossReason(segments, cwd, { always = true, split } = {}) {
|
|
812
|
+
/** The first loss among `segs`, run from `start`; a substitution body is judged as a command. */
|
|
813
|
+
const judge = (segs, start, depth = 0) => {
|
|
814
|
+
let dir = start;
|
|
815
|
+
for (const raw of segs.map(stripStructure)) {
|
|
816
|
+
const text = raw.join(' ');
|
|
817
|
+
const named = LOSS_CANDIDATE_RE.test(text);
|
|
818
|
+
for (const body of named ? substitutions(text) : []) {
|
|
819
|
+
if (!LOSS_CANDIDATE_RE.test(body)) continue;
|
|
820
|
+
// Bounded: `$($($(…` nested past any real command would rescan the text once per level.
|
|
821
|
+
if (!split || depth >= 4) unverified('a command substitution names a removal');
|
|
822
|
+
const v = judge(split(body), dir, depth + 1);
|
|
823
|
+
if (v) return v;
|
|
824
|
+
}
|
|
730
825
|
let w = words(raw);
|
|
731
|
-
while (w.length && (/^\w+=/.test(w[0]) || WRAPPERS.has(progOf(w[0])))) {
|
|
826
|
+
while (w && w.length && (/^\w+=/.test(w[0]) || WRAPPERS.has(progOf(w[0])))) {
|
|
732
827
|
const wrapper = progOf(w[0]);
|
|
733
|
-
w = w.slice(1);
|
|
734
|
-
|
|
735
|
-
if (wrapper !== 'command' && wrapper !== 'exec') while (w.length && w[0].startsWith('-')) w = w.slice(1);
|
|
828
|
+
w = /^\w+=/.test(w[0]) ? w.slice(1) : pastWrapper(wrapper, w.slice(1));
|
|
829
|
+
if (!w && named) unverified(`\`${wrapper}\` takes a flag the gate does not know`);
|
|
736
830
|
}
|
|
737
|
-
if (!w.length) continue;
|
|
831
|
+
if (!w || !w.length) continue;
|
|
738
832
|
const prog = progOf(w[0]);
|
|
739
833
|
const args = w.slice(1);
|
|
740
834
|
if (CD_PROGS.has(prog)) {
|
|
@@ -747,10 +841,18 @@ function lossReason(segments, cwd, { always = true } = {}) {
|
|
|
747
841
|
else if (RM_PROGS.has(prog)) verdict = rmAct(prog, args, dir);
|
|
748
842
|
else if (INDIRECT.has(prog) && LOSS_CANDIDATE_RE.test(args.join(' '))) {
|
|
749
843
|
unverified(`\`${prog}\` runs a command the gate cannot read`);
|
|
844
|
+
} else if (named && !INERT.has(prog)
|
|
845
|
+
&& LOSS_CANDIDATE_RE.test(raw.map((t) => t.replace(/"[^"]*"?|'[^']*'?/g, ' ')).join(' '))) {
|
|
846
|
+
// A program this reader does not know (a wrapper, a loop, shell syntax) over an unquoted
|
|
847
|
+
// removal word: it may run it, so it is not proven safe. A quoted word is data.
|
|
848
|
+
unverified(`\`${w[0].replace(OPAQUE, '')}\` is not a program the gate reads, and a removal follows it`);
|
|
750
849
|
}
|
|
751
850
|
if (verdict) return verdict;
|
|
752
851
|
}
|
|
753
852
|
return null;
|
|
853
|
+
};
|
|
854
|
+
try {
|
|
855
|
+
return judge(segments, cwd);
|
|
754
856
|
} catch (e) {
|
|
755
857
|
if (!(e instanceof Unverified)) throw e;
|
|
756
858
|
return `the gate could not verify what this command would lose (${e.message})`;
|
|
@@ -776,9 +878,9 @@ const FS_ROOT_RE = /^(?:\/|\/[a-z]\/?|\/mnt\/[a-z]\/?|[a-z]:[\\/]?)\*?$/i
|
|
|
776
878
|
const SCAN_WORD_RE = /\b(?:find|du|tree|rg|fd|where\.exe|grep|egrep|ls|dir|get-childitem|gci)\b/i
|
|
777
879
|
const SCAN_TOKEN_RE = /(?:[^\s"']+|"[^"]*"?|'[^']*'?)+/g
|
|
778
880
|
const SCAN_WRAPPERS = new Set(["sudo", "command", "\\builtin", "time", "nice"])
|
|
779
|
-
// A comment (`#` starting a word), a quoted run (its `;`/`|` is data), a separator
|
|
780
|
-
// opener (never the `<<<` here-string).
|
|
781
|
-
const SCAN_SPLIT_RE = /(?<![^\s;|&(])#[^\n]*|"[^"]*"?|'[^']*'?|\|\||&&|[|;\n]|(?<!<)<<(?!<)(-?)[ \t]*(["']?)([A-Za-z_]\w*)\2/g
|
|
881
|
+
// A comment (`#` starting a word), a quoted run (its `;`/`|` is data), a separator (a lone `&`
|
|
882
|
+
// too, never the one in `2>&1`/`<&3`/`&>`/`|&`), or a heredoc opener (never the `<<<` here-string).
|
|
883
|
+
const SCAN_SPLIT_RE = /(?<![^\s;|&(])#[^\n]*|"[^"]*"?|'[^']*'?|\|\||&&|[|;\n]|(?<![&<>|])&(?![&>])|(?<!<)<<(?!<)(-?)[ \t]*(["']?)([A-Za-z_]\w*)\2/g
|
|
782
884
|
// Cut at separators OUTSIDE quotes and drop every heredoc body (to its TAG line; `<<-` allows
|
|
783
885
|
// tabs): a commit message that names a root scan is data, not a command to block.
|
|
784
886
|
function scanSegments(command) {
|
|
@@ -812,7 +914,8 @@ function rootScan(command) {
|
|
|
812
914
|
if (!SCAN_WORD_RE.test(command)) return null
|
|
813
915
|
for (const seg of scanSegments(command)) {
|
|
814
916
|
// Quotes kept until the root test, so a quoted "a:" is told from a bare C:.
|
|
815
|
-
|
|
917
|
+
// `(find / …)`, `then find / …`: the grammar around the command is not its program.
|
|
918
|
+
let toks = stripStructure(seg.match(SCAN_TOKEN_RE) || []).filter((t) => !/^\w+=/.test(t))
|
|
816
919
|
while (toks.length && SCAN_WRAPPERS.has(unquote(toks[0]))) toks = toks.slice(1)
|
|
817
920
|
if (!toks.length) continue
|
|
818
921
|
// The program word without its path (either slash) or an `.exe` suffix.
|
|
@@ -1218,7 +1321,8 @@ export const GeneseedGuard = async (ctx) => {
|
|
|
1218
1321
|
if (c && !DELETION_GATE_OFF && LOSS_CANDIDATE_RE.test(c)) {
|
|
1219
1322
|
// OpenCode's bash tool may run in `workdir`, resolved against the session directory.
|
|
1220
1323
|
const wd = typeof args.workdir === "string" && args.workdir ? path.resolve(cwd, args.workdir) : cwd
|
|
1221
|
-
const
|
|
1324
|
+
const cut = (t) => scanSegments(t).map((seg) => seg.match(SCAN_TOKEN_RE) || [])
|
|
1325
|
+
const loss = lossReason(cut(c), wd, { always: false, split: cut })
|
|
1222
1326
|
if (loss) { await deny(`${loss} (Deletion Is Deliberate) — confirm with the user first`, "law-4"); return }
|
|
1223
1327
|
}
|
|
1224
1328
|
}
|
package/docs/web-ui.md
CHANGED
|
@@ -165,8 +165,9 @@ streams the output of background jobs and keeps their history across reloads.
|
|
|
165
165
|
added / missing files with per-file expansion and coloured unified-diff lines. Select
|
|
166
166
|
files to **export an `improvements.md`** back-port, or **restore** them to source.
|
|
167
167
|
- **Doctor** (`#/doctor`) — runs the same health engine as the `doctor` command (every
|
|
168
|
-
theme, parity, links, authoring gates, drift), grouping any problems per check.
|
|
169
|
-
|
|
168
|
+
theme, parity, links, authoring gates, drift), grouping any problems per check. It shows
|
|
169
|
+
the server's last run — warmed at start, redone after a job, an install toggle or a view
|
|
170
|
+
switch — and **Re-run checks** runs every check again on demand.
|
|
170
171
|
|
|
171
172
|
### 🎨 Setup
|
|
172
173
|
|
package/harness.config.json
CHANGED
|
@@ -2,5 +2,5 @@
|
|
|
2
2
|
"_comment": "version is a human-readable release label only; the canonical identity of an installed harness is the source fingerprint stamped in .geneseed-version (see `harness version`), not this string.",
|
|
3
3
|
"theme": "neutral",
|
|
4
4
|
"product": "Geneseed",
|
|
5
|
-
"version": "4.9.
|
|
5
|
+
"version": "4.9.1"
|
|
6
6
|
}
|
package/js/hosts/hooks.mjs
CHANGED
|
@@ -58,7 +58,7 @@ import {
|
|
|
58
58
|
GATE_LEDGER, globalHookStandingDown, hookProjectDir, resolvePath, sovereignBypass,
|
|
59
59
|
} from './hosts.mjs';
|
|
60
60
|
import { currentBranch, gitRootOf, loopLaunched } from './gitref.mjs';
|
|
61
|
-
import { LOSS_CANDIDATE_RE, lossReason } from './loss.mjs';
|
|
61
|
+
import { LOSS_CANDIDATE_RE, lossReason, stripStructure } from './loss.mjs';
|
|
62
62
|
import { selfAndParents, readStdin } from './hooks-prims.mjs';
|
|
63
63
|
import { recordUse } from './usage.mjs';
|
|
64
64
|
|
|
@@ -349,13 +349,14 @@ const SCAN_WORD_RE = /\b(?:find|du|tree|rg|fd|where\.exe|grep|egrep|ls|dir|get-c
|
|
|
349
349
|
const SCAN_TOKEN_RE = /(?:[^\s"']+|"[^"]*"?|'[^']*'?)+/g;
|
|
350
350
|
const SCAN_WRAPPERS = new Set(['sudo', 'command', '\\builtin', 'time', 'nice']);
|
|
351
351
|
// One left-to-right pass: a comment (`#` starting a word, to the end of the line — an apostrophe
|
|
352
|
-
// in it is no quote), a quoted run (kept whole, so its `;`/`|` is data), a separator
|
|
352
|
+
// in it is no quote), a quoted run (kept whole, so its `;`/`|` is data), a separator (a lone `&`
|
|
353
|
+
// backgrounds a command and runs the next, but `2>&1`, `<&3`, `&>` and `|&` are not one), or a
|
|
353
354
|
// heredoc opener (`<<TAG`, `<<-TAG`, `<< 'TAG'`, `<<"TAG"` — never the here-string `<<<`).
|
|
354
355
|
// Every alternative consumes at least one character, and none can backtrack past its start.
|
|
355
|
-
const SCAN_SPLIT_RE = /(?<![^\s;|&(])#[^\n]*|"[^"]*"?|'[^']*'?|\|\||&&|[|;\n]|(?<!<)<<(?!<)(-?)[ \t]*(["']?)([A-Za-z_]\w*)\2/g;
|
|
356
|
+
const SCAN_SPLIT_RE = /(?<![^\s;|&(])#[^\n]*|"[^"]*"?|'[^']*'?|\|\||&&|[|;\n]|(?<![&<>|])&(?![&>])|(?<!<)<<(?!<)(-?)[ \t]*(["']?)([A-Za-z_]\w*)\2/g;
|
|
356
357
|
|
|
357
358
|
/**
|
|
358
|
-
* The command cut at
|
|
359
|
+
* The command cut at `|`/`;`/`&`/`&&`/`||`/newline OUTSIDE quotes, with every heredoc body (the
|
|
359
360
|
* lines after `<<TAG` up to the `TAG` line; `<<-` lets it be tab-indented) dropped: both are data.
|
|
360
361
|
* Without this, `git commit -F - <<'EOF'` — the mandated multi-line commit form — or a quoted
|
|
361
362
|
* `-m "fix; find / loop"` whose text merely names a root scan reads as one, which on Bob and
|
|
@@ -401,7 +402,8 @@ export function rootScan(command) {
|
|
|
401
402
|
if (!SCAN_WORD_RE.test(command)) return null;
|
|
402
403
|
for (const seg of scanSegments(command)) {
|
|
403
404
|
// Tokens keep their quotes until the root test, so a quoted `"a:"` can be told from a bare `C:`.
|
|
404
|
-
|
|
405
|
+
// `(find / …)`, `then find / …`: the grammar around the command is not its program.
|
|
406
|
+
let toks = stripStructure(seg.match(SCAN_TOKEN_RE) || []).filter((t) => !/^\w+=/.test(t));
|
|
405
407
|
while (toks.length && SCAN_WRAPPERS.has(unquote(toks[0]))) toks = toks.slice(1);
|
|
406
408
|
if (!toks.length) continue;
|
|
407
409
|
// The program word without its path (either slash) or an `.exe` suffix: `C:\tools\rg.exe`.
|
|
@@ -427,6 +429,8 @@ export function rootScan(command) {
|
|
|
427
429
|
return null;
|
|
428
430
|
}
|
|
429
431
|
const unquote = (t) => t.replace(/^["']+|["']+$/g, '');
|
|
432
|
+
// The segments `lossReason` reads: raw tokens, quotes still on — also how it cuts a `$(…)` body.
|
|
433
|
+
const lossSegments = (s) => scanSegments(s).map((seg) => seg.match(SCAN_TOKEN_RE) || []);
|
|
430
434
|
// A QUOTED token is data — a grep pattern (`grep -rn "/" src`), a path named in a search
|
|
431
435
|
// (`rg "/mnt/c" README.md`) — never a root target; only an unquoted root is one.
|
|
432
436
|
const rootArg = (t) => !/^["']/.test(t) && FS_ROOT_RE.test(t);
|
|
@@ -439,8 +443,8 @@ function gitDecide(args, payload) {
|
|
|
439
443
|
if (!args.noDeletionGate && LOSS_CANDIDATE_RE.test(command)) {
|
|
440
444
|
// A pass here FALLS THROUGH: a force push to a feature branch loses nothing, but it is still
|
|
441
445
|
// a push, and process 5 below still asks for it.
|
|
442
|
-
const loss = lossReason(
|
|
443
|
-
|
|
446
|
+
const loss = lossReason(lossSegments(command), (payload && payload.cwd) || process.cwd(),
|
|
447
|
+
{ split: lossSegments });
|
|
444
448
|
if (loss) {
|
|
445
449
|
return ask(args, 'git-gate', 'law-4', `Geneseed (Deletion Is Deliberate) \u2014 ${loss}. `
|
|
446
450
|
+ 'Confirm this specific command, or leave the data in place.');
|
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/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/web/actions.mjs
CHANGED
|
@@ -307,7 +307,8 @@ export function apiMemoryDelete(state, name) {
|
|
|
307
307
|
// CASE-FOLDED: this endpoint's own compared `MEMORY` exactly, so on Windows and macOS `memory`
|
|
308
308
|
// resolved to MEMORY.md, passed, and deleted the index the agent reads at session start.
|
|
309
309
|
if (memoryDelete(d, name) === null) throw new NotFound(name);
|
|
310
|
-
|
|
310
|
+
// The doctor reads nothing in the memory store, so its cached verdict survives (`refresh`).
|
|
311
|
+
state.refresh({ doctor: false });
|
|
311
312
|
return { archived: name };
|
|
312
313
|
}
|
|
313
314
|
|
package/js/web/api.mjs
CHANGED
|
@@ -16,7 +16,6 @@ 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 {
|
|
@@ -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
|
/**
|
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.
|