@vincemakes/kiso-code 0.39.1 → 0.40.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.
@@ -3,8 +3,10 @@
3
3
  * turns. The bodies moved verbatim from chat()'s closure; chat provides
4
4
  * the context (the chain, the run state, the prompt arming).
5
5
  */
6
+ import type { SessionRoute } from "./projects.js";
6
7
  import type { AgentSession } from "@vincemakes/kiso-runtime";
7
8
  import { type LineInput } from "./state.js";
9
+ import type { UserInputVia } from "@vincemakes/kiso-core";
8
10
  /** Everything dispatch touches that chat() owns. */
9
11
  export interface DispatchCtx {
10
12
  readonly session: AgentSession;
@@ -23,8 +25,10 @@ export interface DispatchCtx {
23
25
  * gone; a binding that has not run yet paints none — an unmeasured
24
26
  * cache is not a 0% cache, and it is not the previous model's either. */
25
27
  readonly modelSwitched: () => void;
26
- /** submit a real turn: queue + chain (the turn closure lives in chat). */
27
- readonly submitTurn: (line: string) => void;
28
+ /** submit a real turn: queue + chain (the turn closure lives in chat).
29
+ * 0.40.0: `via` marks a skill turn — `line` is then the composed
30
+ * content, and the chip shows `via.line`. */
31
+ readonly submitTurn: (line: string, via?: UserInputVia) => void;
28
32
  /** the /status context estimate. */
29
33
  readonly estimateCtx: () => number;
30
34
  /** TUI2-R1 (E): the model's context window, as the session is
@@ -38,6 +42,10 @@ export interface DispatchCtx {
38
42
  readonly requestReload: () => void;
39
43
  /** every durable session id (the /resume validation + listing). */
40
44
  readonly sessions: () => readonly string[];
45
+ /** 0.40.0: an id outside this project's folder — refused with where to
46
+ * go, or opened where it is with a line (absent: every such id is "no
47
+ * such"). */
48
+ readonly route?: (id: string) => SessionRoute | null;
41
49
  /** the dock's session picker, when one exists (bare /resume). */
42
50
  readonly pickSession?: () => Promise<string | null>;
43
51
  }
package/dist/dispatch.js CHANGED
@@ -3,22 +3,53 @@
3
3
  * turns. The bodies moved verbatim from chat()'s closure; chat provides
4
4
  * the context (the chain, the run state, the prompt arming).
5
5
  */
6
- import { contextRows, contextUnavailableRows, displayVerb, escapeTerminal, helpRows, kUnit, modePickView, modelPickView, palette, renderEvent, settledLabel, slashCommandNames } from "@vincemakes/kiso-tui";
6
+ import { STATUS_GLYPHS, contextRows, contextUnavailableRows, displayVerb, escapeTerminal, helpRows, kUnit, modePickView, modelPickView, compactingStatus, palette, renderEvent, settledLabel, slashCommandNames } from "@vincemakes/kiso-tui";
7
7
  import { newSessionId } from "./session-id.js";
8
8
  import { buildAdapter, lookupModelMetadata, resolveContinuationScope, resolveReasoning } from "@vincemakes/kiso-runtime/internal";
9
- import { MODES, MODE_NOTE, getMode, setMode } from "./mode.js";
9
+ import { MODES, MODE_NOTE, OFFERED_MODES, getMode, setMode } from "./mode.js";
10
10
  import { clipboardWrite, lastAnswer } from "./clipboard.js";
11
- import { agentModel, body, bodyLog, codingToolOptions, kisoHome, configModels, dock, lastBinding, mergedConfig, readContextLedger, sessionsDir, setAgentModel, setConfiguredWindow, setCurrentModelName, setModelChoice, setLastBinding } from "./state.js";
11
+ import { protectedBangReason, protectedShellVerdict } from "./protected-shell.js";
12
+ import { agentModel, body, bodyLog, codingToolOptions, protectedFiles, kisoHome, configModels, dock, lastBinding, loadedSkillsCatalog, mergedConfig, readContextLedger, retryOnRow, sessionsDir, setAgentModel, setConfiguredWindow, setCurrentModelName, setModelChoice, setRetryShown, setLastBinding } from "./state.js";
12
13
  import { adapterOptionsFor } from "./auth/adapter-options.js";
13
- import { microcompactThresholdFor } from "./chat.js";
14
+ import { contextWindowTokens, microcompactThresholdFor, startStatusSpinner } from "./chat.js";
14
15
  import { authForProfile, directWriteProfile, profileAvailable, resolveContextWindow, unavailableReason } from "./config.js";
15
16
  import { shellTool } from "@vincemakes/kiso-tools-node";
16
- import { join } from "node:path";
17
+ import { dirname, join } from "node:path";
18
+ import { realpathSync } from "node:fs";
19
+ import { homedir } from "node:os";
20
+ import { resolveSkillLine, skillsRows } from "./skill-invoke.js";
21
+ /** Where a skill directory really lives. Project and user skills are
22
+ * merged into one scan directory by symlink, so the scan root names a
23
+ * temp directory; the link's target is the answer a person can use. */
24
+ function skillSource(dir) {
25
+ const scan = process.env.KISO_SKILLS_DIR ?? join(kisoHome(), "skills");
26
+ try {
27
+ return tildePath(dirname(realpathSync(join(scan, dir))));
28
+ }
29
+ catch {
30
+ return tildePath(scan); // a broken link has no target to name
31
+ }
32
+ }
33
+ function tildePath(p) {
34
+ const home = homedir();
35
+ return p === home || p.startsWith(`${home}/`) ? `~${p.slice(home.length)}` : p;
36
+ }
17
37
  /** The picker's CLI half (owner 2026-09-08): a profile's LEGAL effort levels
18
38
  * and its default, from the registry, shown wherever the profile is listed
19
39
  * — kiso never silently drops a level, and now it shows the ones it takes.
20
40
  * A model the registry does not know shows `unknown` (nothing is guessed);
21
41
  * it must not read `none`, which is a REAL level since the Responses rows. */
42
+ /** A context figure for a NOTICE, in the status row's own form.
43
+ *
44
+ * `displayCtxRatio` returns NaN as its honest "nobody states this model's
45
+ * window", and the status row renders that as `ctx ?`. The `/compact`
46
+ * recap multiplied it and interpolated the result, so the same unknown
47
+ * reached the screen as `ctx NaN% → NaN%`: one value with a contract, and
48
+ * a second consumer that had not read it. The `%` moves inside, because
49
+ * the unknown form does not carry one. */
50
+ function ctxPercent(ratio) {
51
+ return Number.isFinite(ratio) ? `${Math.round(ratio * 100)}%` : "?";
52
+ }
22
53
  function effortNote(p) {
23
54
  const reasoning = lookupModelMetadata(p.model, p.baseUrl)?.capabilities.reasoning ?? null;
24
55
  const effort = reasoning?.effort ?? null;
@@ -119,9 +150,21 @@ export function parseBang(trimmed) {
119
150
  * No approval panel, at any tier including plan: the human typed the
120
151
  * command. That is not a policy exemption, it is structural — this
121
152
  * dispatcher only ever sees lines a human submitted, so a model cannot
122
- * reach this branch. */
153
+ * reach this branch.
154
+ *
155
+ * One check it does take: kiso never serves its own credential store to a
156
+ * model, and `!cmd` hands its output to one. `!!cmd` does not — but the
157
+ * two are one keystroke apart, so a line naming a protected file runs
158
+ * under neither, and the store stays one `cat` away in the person's own
159
+ * terminal. */
123
160
  function runBang(command, send, ctx) {
124
161
  ctx.chainRef.current = ctx.chainRef.current.then(async () => {
162
+ const guard = protectedShellVerdict(command, codingToolOptions().workspaceRoot, protectedFiles(), { home: homedir(), kisoHome: kisoHome() });
163
+ if (guard.refused) {
164
+ bodyLog(protectedBangReason(guard));
165
+ ctx.input.prompt();
166
+ return;
167
+ }
125
168
  const controller = new AbortController();
126
169
  activeBang = controller;
127
170
  try {
@@ -458,7 +501,7 @@ export function dispatch(line, ctx) {
458
501
  const picked = await new Promise((resolve) => {
459
502
  ctx.input.panelAsk(modePickView({
460
503
  header: `mode — current: ${current}`,
461
- options: MODES.map((name) => ({ label: name, note: [MODE_NOTE[name], ...(name === current ? ["current"] : [])].join(" · ") })),
504
+ options: OFFERED_MODES.map((name) => ({ label: name, note: [MODE_NOTE[name], ...(name === current ? ["current"] : [])].join(" · ") })),
462
505
  },
463
506
  // DC-12 (design §4): a panel WAITING ON A HUMAN says ❯.
464
507
  ctx.isRunning() ? "❯ run paused" : `▸ ${current}`), (v) => resolve(v.action === "picked" ? v.result : null));
@@ -468,10 +511,10 @@ export function dispatch(line, ctx) {
468
511
  ctx.input.prompt();
469
512
  return; // esc — nothing switched, nothing said
470
513
  }
471
- const chosen = "index" in picked ? MODES[picked.index] : MODES.find((x) => x === picked.custom.trim());
514
+ const chosen = "index" in picked ? OFFERED_MODES[picked.index] : MODES.find((x) => x === picked.custom.trim());
472
515
  if (chosen === undefined) {
473
516
  bodyLog(`no such mode: ${"index" in picked ? String(picked.index) : picked.custom.trim()}`);
474
- bodyLog(`tiers: ${MODES.join(" ")}`);
517
+ bodyLog(`tiers: ${OFFERED_MODES.join(" ")}`);
475
518
  }
476
519
  else {
477
520
  setMode(chosen);
@@ -482,11 +525,11 @@ export function dispatch(line, ctx) {
482
525
  return;
483
526
  }
484
527
  bodyLog(`mode ${getMode()}`);
485
- bodyLog(`tiers: ${MODES.join(" ")}`);
528
+ bodyLog(`tiers: ${OFFERED_MODES.join(" ")}`);
486
529
  }
487
530
  else if (m === undefined) {
488
531
  bodyLog(`no such mode: ${trimmed.slice(5).trim()}`);
489
- bodyLog(`tiers: ${MODES.join(" ")}`);
532
+ bodyLog(`tiers: ${OFFERED_MODES.join(" ")}`);
490
533
  }
491
534
  else {
492
535
  setMode(m);
@@ -655,11 +698,16 @@ export function dispatch(line, ctx) {
655
698
  ...(profile.baseUrl !== undefined ? { baseUrl: profile.baseUrl } : {}),
656
699
  }),
657
700
  },
701
+ // A1b: the in-run tiers are drawn from the same window.
702
+ contextWindow: contextWindowTokens({ model: profile.model, ...(profile.baseUrl !== undefined ? { baseUrl: profile.baseUrl } : {}) }),
658
703
  // OR-1: the endpoint is the fourth passenger — the cost
659
704
  // path and the window lookup key on (model, endpoint).
660
705
  ...(profile.baseUrl !== undefined ? { baseUrl: profile.baseUrl } : {}),
661
706
  ...(scope !== undefined ? { scope } : {}),
662
707
  ...(reasoning !== undefined ? { reasoning } : {}),
708
+ // 0.40.0: the NAME the person switched to, recorded for
709
+ // display — a direct provider/model has none.
710
+ profileName: direct === null ? profName : null,
663
711
  };
664
712
  ctx.session.setModelBinding(binding);
665
713
  setLastBinding(binding);
@@ -721,7 +769,12 @@ export function dispatch(line, ctx) {
721
769
  };
722
770
  ctx.input.onEscape(onEscape);
723
771
  let compactStart = 0;
724
- let compactTimer = null;
772
+ let repaintCompacting = () => { };
773
+ // 0.40.0 (the owner's dogfood): the row walks the WORKING twinkle,
774
+ // the same spinner as a running turn — a static glyph repainted
775
+ // once a second read as a stalled screen while the model thought
776
+ let glyph = STATUS_GLYPHS[0];
777
+ let stopSpinner = null;
725
778
  // the `as` on the initializer keeps the flow type the full union —
726
779
  // onStart fills this during the call, but a closure assignment
727
780
  // never re-narrows the outer scope (it would read `never`)
@@ -729,27 +782,51 @@ export function dispatch(line, ctx) {
729
782
  // the ctx estimate BEFORE the summarized event lands (the used
730
783
  // fraction — the recap's "ctx 91% → 34%" drops after compacting)
731
784
  let ctxBefore = null;
785
+ // 0.40.0: the summary call's progress against its output budget —
786
+ // the latest report, read by the row; the runtime reports per
787
+ // streamed chunk and the row repaints at most every 200 ms
788
+ let progress = null;
789
+ let lastProgressPaint = 0;
732
790
  const compacting = (info) => {
733
- const text = (elapsed) => `▘ compacting · ${info.rounds} rounds · ~${kUnit(info.tokens)} tokens · ${Math.max(0, elapsed)}s`;
791
+ // 0.40.0: composed by the row seam, not a template here — the row
792
+ // is where the launch build's progress segment plugs in.
793
+ const text = (elapsed) => compactingStatus(glyph, info.rounds, info.tokens, elapsed, undefined, retryOnRow(), progress);
794
+ repaintCompacting = () => dock.setStatus(text(Math.round((Date.now() - compactStart) / 1000)), "esc to cancel");
734
795
  compactStart = Date.now();
735
- ctxBefore = Math.round(ctx.estimateCtx() * 100);
796
+ ctxBefore = ctxPercent(ctx.estimateCtx());
736
797
  dock.setStatus(text(0), "esc to cancel");
737
- compactTimer = setInterval(() => {
738
- dock.setStatus(text(Math.round((Date.now() - compactStart) / 1000)), "esc to cancel");
739
- }, 1000);
798
+ stopSpinner = startStatusSpinner((next) => {
799
+ glyph = next;
800
+ repaintCompacting();
801
+ });
740
802
  };
741
803
  try {
742
804
  // R3a: /compact <focus> — the words after the command steer
743
805
  // the summary ("keep the auth details"); bare /compact is
744
806
  // byte-identical to the pre-round call.
745
807
  const focus = trimmed.slice(8).trim();
808
+ setRetryShown(null);
746
809
  const result = await ctx.session.summarize({
747
810
  signal: abort.signal,
811
+ // 0.39.2: the gesture gets the measured output budget and
812
+ // thinking off where the registry can vouch for turning it
813
+ // off. A fixed 4,000 was failing live sessions with "the
814
+ // summary turn ended with max_tokens"; a complete checkpoint
815
+ // of the reported size measured 18,837.
816
+ manualBudget: true,
748
817
  ...(focus !== "" ? { focus } : {}),
749
818
  onStart: (info) => {
750
819
  compactInfo = info;
751
820
  compacting(info);
752
821
  },
822
+ onProgress: (p) => {
823
+ progress = { produced: p.produced, budget: p.budget, reasoningUnseen: p.reasoningUnseen };
824
+ const now = Date.now();
825
+ if (now - lastProgressPaint < 200)
826
+ return;
827
+ lastProgressPaint = now;
828
+ repaintCompacting();
829
+ },
753
830
  });
754
831
  if (result === null) {
755
832
  body.notice("[/compact] nothing to compact — fewer than 5 rounds yet");
@@ -760,11 +837,11 @@ export function dispatch(line, ctx) {
760
837
  // rounds, the one summary, the savings, the ctx drop
761
838
  // (the estimate BEFORE vs AFTER — the same chars/4
762
839
  // proxy the status bar shows, marked ~), and the time.
763
- const ctxAfter = Math.round(ctx.estimateCtx() * 100);
840
+ const ctxAfter = ctxPercent(ctx.estimateCtx());
764
841
  const elapsed = compactStart > 0 ? settledLabel((Date.now() - compactStart) / 1000) : "?s";
765
842
  // a non-null result implies onStart ran — the "?" is
766
843
  // reachable only at the type level
767
- body.notice(`[/compact] ✦ compacted · ${compactInfo?.rounds ?? "?"} rounds → 1 summary · saved ~${kUnit(result.savedTokens)} · ctx ${ctxBefore ?? "?"}% → ${ctxAfter}% · ${elapsed}`);
844
+ body.notice(`[/compact] ✦ compacted · ${compactInfo?.rounds ?? "?"} rounds → 1 summary · saved ~${kUnit(result.savedTokens)} · ctx ${ctxBefore ?? "?"} → ${ctxAfter} · ${elapsed}`);
768
845
  // 0.39.1 — the boundary row, under the recap.
769
846
  //
770
847
  // `summarized` is appended OFF-LOOP: session.summarize()
@@ -811,8 +888,10 @@ export function dispatch(line, ctx) {
811
888
  }
812
889
  }
813
890
  finally {
814
- if (compactTimer !== null)
815
- clearInterval(compactTimer);
891
+ stopSpinner?.();
892
+ // ADR-0005 Amendment 2: a retry belongs to the call that
893
+ // announced it — none may outlive the compaction into the idle row.
894
+ setRetryShown(null);
816
895
  ctx.paintIdle();
817
896
  }
818
897
  ctx.input.prompt();
@@ -893,8 +972,15 @@ export function dispatch(line, ctx) {
893
972
  // would; the validation is the difference
894
973
  if (!ctx.sessions().includes(arg)) {
895
974
  ctx.chainRef.current = ctx.chainRef.current.then(async () => {
896
- bodyLog(`no such session: ${escapeTerminal(arg)} — /resume lists them`);
897
- ctx.input.prompt();
975
+ const route = ctx.route?.(arg) ?? null;
976
+ if (route === null || route.kind === "refused") {
977
+ bodyLog(route?.line ?? `no such session: ${escapeTerminal(arg)} — /resume lists them`);
978
+ ctx.input.prompt();
979
+ return;
980
+ }
981
+ if (route.kind === "elsewhere" && route.line !== null)
982
+ bodyLog(route.line);
983
+ ctx.requestSwitch(arg);
898
984
  });
899
985
  return;
900
986
  }
@@ -916,6 +1002,16 @@ export function dispatch(line, ctx) {
916
1002
  ctx.input.prompt(); // esc — nothing switched, nothing said
917
1003
  return;
918
1004
  }
1005
+ // 0.40.0: `tab` lists every project; another project's recorded
1006
+ // session is refused with where to go
1007
+ const route = ctx.sessions().includes(picked) ? null : (ctx.route?.(picked) ?? null);
1008
+ if (route?.kind === "refused") {
1009
+ bodyLog(route.line);
1010
+ ctx.input.prompt();
1011
+ return;
1012
+ }
1013
+ if (route?.kind === "elsewhere" && route.line !== null)
1014
+ bodyLog(route.line);
919
1015
  ctx.requestSwitch(picked);
920
1016
  });
921
1017
  return;
@@ -928,6 +1024,25 @@ export function dispatch(line, ctx) {
928
1024
  });
929
1025
  return;
930
1026
  }
1027
+ // 0.40.0 — a person's skill: `/skills`, `/skill <name> [args]`, or
1028
+ // `/<name> [args]` for a name no built-in above claimed. Placed AFTER
1029
+ // every built-in so a built-in always wins, and BEFORE the unknown-
1030
+ // command line so an installed skill is never called unknown.
1031
+ const skill = resolveSkillLine(trimmed, loadedSkillsCatalog(), slashCommandNames());
1032
+ if (trimmed === "/skills" || trimmed === "/skill" || skill !== null) {
1033
+ if (skill?.kind === "submit") {
1034
+ ctx.submitTurn(skill.content, skill.via);
1035
+ return;
1036
+ }
1037
+ ctx.chainRef.current = ctx.chainRef.current.then(async () => {
1038
+ if (skill?.kind === "list")
1039
+ bodyLog(skillsRows(loadedSkillsCatalog(), skillSource, tildePath(join(kisoHome(), "skills"))).map(escapeTerminal).join("\n"));
1040
+ else if (skill?.kind === "error")
1041
+ bodyLog(escapeTerminal(skill.message));
1042
+ ctx.input.prompt();
1043
+ });
1044
+ return;
1045
+ }
931
1046
  // PH-1a (finding PH-F1): an unrecognized slash command is an ERROR,
932
1047
  // never a turn — the fallthrough used to hand "/clear", "/exit", or a
933
1048
  // typo to the model, burning a request on text the user meant as a
@@ -0,0 +1,80 @@
1
+ /**
2
+ * 0.40.0 — the catastrophe floor (launch-weekend plan §4, minimum shape;
3
+ * owner, 2026-09-17: "bypass stays bypass: `rm -rf /tmp/probe` runs. The
4
+ * floor refuses only unrecoverable targets"; the lead's review and rulings
5
+ * of 2026-09-18).
6
+ *
7
+ * A destructive command whose target cannot be recovered is REFUSED, in
8
+ * every mode, bypass included, as a precondition — a chain member that
9
+ * denies, and a deny is what nothing outranks. The destructive commands:
10
+ * `rm`; `git clean -f`, `git reset --hard`, `git checkout -- <paths>` and
11
+ * the same loss spelled `git checkout .` / `-f`, `git restore` touching
12
+ * the worktree, `git switch -f` (R5); `find … -delete` with no selecting
13
+ * primary before it (R6). The unrecoverable targets:
14
+ *
15
+ * - `/`, the home directory, the workspace root or any directory above it;
16
+ * - the workspace's `.git` (R7);
17
+ * - `~/.ssh`, `~/.config`, `~/.kiso`, `~/.gnupg`, `~/.aws`, or inside one;
18
+ * - a system root and what is inside it — except the temp family and the
19
+ * software prefixes (/opt/homebrew, /usr/local, linuxbrew), where only
20
+ * the root itself is, and /Volumes /Users /home /mnt /media, where the
21
+ * root and its direct children are (R8). Inside the workspace or the
22
+ * home directory is never a system root's;
23
+ * - a wildcard over any of those (`~/*`, `/*`, `*` at the workspace root —
24
+ * a component of nothing but globs; `*.log` ranges over SOME entries);
25
+ * - a target that is only a variable (`$DIR/`, `"$X"/*`) — empty, it is
26
+ * the root of whatever comes after it. `$HOME` and `$PWD` are known.
27
+ *
28
+ * This is a reading of the command line, not a guarantee: the sandbox is
29
+ * the guarantee, and it comes after the launch. So the reading errs toward
30
+ * seeing a command — it looks inside `$( )`, backticks, `( )`, `sh -c` and
31
+ * `eval`, through `sudo`/`env`/`timeout`/`command` and shell keywords, and
32
+ * follows `cd` by its joiner — and a line that knocks the reader out is
33
+ * denied. Documented, not refused: `xargs rm`, script files and heredocs,
34
+ * `find -exec rm`, `mv`, `chmod -R`, `rmdir`, `unlink`, `truncate`,
35
+ * `shred`, `>` truncation, `rsync --delete`, `npx rimraf`, `trash`,
36
+ * `fish -c`, `ssh`, `docker`, `node -e` / `python -c` / `perl -e`, brace
37
+ * expansion, `$'…'`, `~+`, popd past one level, nesting past 16.
38
+ *
39
+ * The same resolver as the read-only allow decides where a path goes.
40
+ * Default on; `"floor": "off"` in the USER config turns it off, and a
41
+ * project config cannot — a repository must not be able to lower it.
42
+ */
43
+ import type { PolicyCall } from "@vincemakes/kiso-core";
44
+ import type { KisoExtension } from "@vincemakes/kiso-runtime";
45
+ import { type LooseWord } from "./shell-words.js";
46
+ export type FloorVerdict = {
47
+ readonly refused: false;
48
+ } | {
49
+ readonly refused: true;
50
+ readonly why: string;
51
+ };
52
+ /** Past keywords, `sudo -u x`, `env A=1`, `timeout 5`, `command`, … to the
53
+ * command that runs. Matched by basename: `/usr/bin/env rm` is env. */
54
+ export declare function unwrap(argv: readonly LooseWord[]): readonly LooseWord[];
55
+ /** `sh -c '…'`, `bash -lc '…'`, `eval …`: the command lines inside.
56
+ * eval CONCATENATES its words with spaces and parses the result again —
57
+ * that is its meaning, and `eval 'rm -rf ~'` is read that way. It is also
58
+ * read once more with each word re-quoted, so `eval sh -c 'rm -rf /'`
59
+ * keeps its script whole (B10, the lead's reading): either refusing is a
60
+ * refusal. In the re-quoted reading `~` is already expanded and a word
61
+ * holding an unknown stays raw — its value is unknown to eval too. */
62
+ export declare function innerLines(argv: readonly LooseWord[], home: string): {
63
+ readonly lines: readonly string[];
64
+ readonly subshell: boolean;
65
+ } | null;
66
+ /**
67
+ * Would the floor refuse this command line? `workspaceRoot` is where the
68
+ * shell runs it.
69
+ */
70
+ export declare function floorCheck(commandLine: string, workspaceRoot: string, home?: string): FloorVerdict;
71
+ /** Is there a destructive command anywhere in the line, whatever its
72
+ * target? A saved allow is never inherited by one (plan §4). */
73
+ export declare function isDestructive(commandLine: string): boolean;
74
+ /** The chain member, at the chain's head: DENY or ABSTAIN. */
75
+ export declare function floorExtension(on: () => boolean, workspaceRoot: () => string, check?: typeof floorCheck): KisoExtension;
76
+ /** Plan §4: "destructive commands never inherit a saved allow in the
77
+ * asking modes" — the predicate the saved-allow wrap
78
+ * (protected-writes.ts) is given, keyed on destructiveness, not on the
79
+ * target: a saved allow never runs even `rm -rf build` unasked. */
80
+ export declare function isDestructiveCall(call: PolicyCall): boolean;