@vincemakes/kiso-code 0.45.3 → 0.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/mode.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Modes — the six built-in approval tiers, built ON the E1 policy chain
2
+ * Modes — the built-in approval tiers, built ON the E1 policy chain
3
3
  * (the kernel is untouched). Each tier is an in-process "mode:<name>"
4
4
  * extension whose decide() is live — it only speaks when it is the
5
5
  * CURRENT tier (otherwise abstain = no opinion, ADR-0042), so /mode
@@ -7,18 +7,52 @@
7
7
  * field: an automated denial records decidedBy: "mode:<name>" — the
8
8
  * audit sell. User-level extensions stay on the chain AFTER the mode
9
9
  * tiers; a user deny always wins (the chain's deny>allow>ask
10
- * monotonicity — bypass cannot override an extension deny).
10
+ * monotonicity — full-access cannot override an extension deny).
11
+ *
12
+ * Two questions, two settings (the modes round, 2026-09-30). The TIER
13
+ * answers what the agent may do. The DON'T-ASK SWITCH answers whether
14
+ * kiso may stop for a person, and it composes with every tier: it never
15
+ * grants anything — what would ask is refused, a model's question is not
16
+ * offered, an uncertain execution stays uncertain. The old `dontAsk` tier
17
+ * was that switch welded to default's authority; it is still accepted,
18
+ * and means exactly that.
11
19
  */
12
20
  import { isProtectedWrite } from "./protected-writes.js";
13
- /** Every tier kiso ACCEPTS — from a config, KISO_MODE, --mode, or
14
- * `/mode <name>`. */
15
- export const MODES = ["manual", "default", "accept-edits", "plan", "bypass", "dontAsk"];
16
- /** The tiers kiso OFFERS a person (launch-weekend plan §2): the picker,
17
- * shift+tab, the printed list, the help. `manual` left the offer — a
18
- * saved allow outranks its ask, so "asks for every tool" was never
19
- * quite what it did — and stays accepted, so no config that names it
20
- * breaks. `dontAsk` joined: the unattended tier. */
21
- export const OFFERED_MODES = ["default", "accept-edits", "plan", "dontAsk", "bypass"];
21
+ /** Every tier kiso decides with. */
22
+ export const MODES = ["manual", "default", "accept-edits", "plan", "full-access"];
23
+ /** The tiers kiso OFFERS a person: the picker, shift+tab, the printed
24
+ * list, the help. `manual` left the offer at 0.40.0 — a saved allow
25
+ * outranks its ask, so "asks for every tool" was never quite what it
26
+ * did — and stays accepted, so no config that names it breaks. */
27
+ export const OFFERED_MODES = ["default", "accept-edits", "plan", "full-access"];
28
+ /** What a person reads for each tier — the status row, the picker, the
29
+ * notices. The typed value (config, --mode, KISO_MODE) keeps its hyphen. */
30
+ export const MODE_LABEL = {
31
+ manual: "manual",
32
+ default: "default",
33
+ "accept-edits": "accept edits",
34
+ plan: "plan",
35
+ "full-access": "full access",
36
+ };
37
+ /** Every spelling kiso ever accepted keeps its meaning. `bypass` is
38
+ * full-access's old name. `dontAsk` was a tier: default's decisions with
39
+ * the switch on — so it still means that, and never full-access with the
40
+ * switch on (read that way, an old config would gain authority nobody
41
+ * gave it). */
42
+ const OLD_NAMES = new Map([
43
+ ["bypass", { mode: "full-access" }],
44
+ ["dontAsk", { mode: "default", dontAsk: true }],
45
+ ]);
46
+ /** Every value a config, KISO_MODE, --mode or `/mode <name>` accepts. */
47
+ export const MODE_VALUES = [...MODES, ...OLD_NAMES.keys()];
48
+ /** A typed tier: its value, its label ("full access"), or an old name. */
49
+ export function parseMode(raw) {
50
+ const s = raw.trim();
51
+ const m = MODES.find((x) => x === s || MODE_LABEL[x] === s);
52
+ if (m !== undefined)
53
+ return { mode: m };
54
+ return OLD_NAMES.get(s);
55
+ }
22
56
  /** DC-36 — one line per tier, for the picker, TRANSCRIBED FROM decide()
23
57
  * below rather than written fresh. A description that drifts from the
24
58
  * behaviour is worse than none: this is the row a human reads before
@@ -41,25 +75,95 @@ export const MODE_NOTE = {
41
75
  default: "read-only runs; the rest asks — a saved allow still allows",
42
76
  "accept-edits": "read-only, edits run; rest asks — a saved allow still allows",
43
77
  plan: "reads run; all else is denied — read-only, and a deny wins",
44
- bypass: "everything runs, nothing asks — a user deny still wins",
45
- dontAsk: "asks nothing: an ask is denied — a saved allow still allows",
78
+ "full-access": "runs without asking — a user deny and the floor still win",
46
79
  };
80
+ /** The switch in one line, for a surface that describes it, under the
81
+ * same 60-column rule. The /mode panel does not offer it: don't ask is
82
+ * not a tier, so it never sits beside them (owner, 2026-09-30) — the
83
+ * panel's header names it when it is on. */
84
+ export const DONT_ASK_NOTE = "never asks: what would ask is refused and the run goes on";
47
85
  /** The read-only tool set (plan): reading is allowed, everything else
48
86
  * denied with the guiding reason. */
49
87
  const READ_TOOLS = new Set(["read_file", "list_dir", "search_text", "read_skill"]);
88
+ /** ADR-0058 (3b, D2): stopping a task this session started asks no one —
89
+ * stopping is the safe direction — in every tier but manual. */
90
+ const STOP_TOOLS = new Set(["task_stop"]);
50
91
  let current = "default";
92
+ let dontAsk = "off";
51
93
  export function getMode() {
52
94
  return current;
53
95
  }
96
+ /** A tier switch. The switch stays as it is — unless it came with the
97
+ * old dontAsk tier, which this switch leaves. */
54
98
  export function setMode(m) {
55
99
  current = m;
100
+ if (dontAsk === "old-name")
101
+ dontAsk = "off";
102
+ }
103
+ /** A typed tier, old names included: `/mode dontAsk` is default with the
104
+ * switch on, exactly the tier it used to be. */
105
+ export function applyModeSetting(s) {
106
+ setMode(s.mode);
107
+ if (s.dontAsk === true && dontAsk === "off")
108
+ dontAsk = "old-name";
109
+ }
110
+ /** Whether kiso may stop for a person — every ask endpoint reads this. */
111
+ export function getDontAsk() {
112
+ return dontAsk !== "off";
113
+ }
114
+ export function setDontAsk(on) {
115
+ dontAsk = on ? "on" : "off";
116
+ }
117
+ /** The tier as the status row and the notices name it, with the switch
118
+ * beside it when it is on. */
119
+ export function modeDisplay() {
120
+ return `${current === "plan" ? "plan (read-only)" : MODE_LABEL[current]}${dontAsk !== "off" ? " · don't ask" : ""}`;
121
+ }
122
+ /** KISO_MODE and KISO_DONT_ASK. A value kiso cannot read is no value —
123
+ * the next layer decides, as an unknown KISO_MODE always did. */
124
+ export function envModeLayer(env = process.env) {
125
+ const raw = env.KISO_DONT_ASK?.trim().toLowerCase();
126
+ const on = raw === "1" || raw === "true" || raw === "on" ? true : raw === "0" || raw === "false" || raw === "off" ? false : undefined;
127
+ return { ...(env.KISO_MODE !== undefined ? { mode: env.KISO_MODE } : {}), ...(on !== undefined ? { dontAsk: on } : {}) };
128
+ }
129
+ /**
130
+ * Precedence: flag > env > project config > user config > default, for
131
+ * each answer on its own. The tier comes from the highest layer that
132
+ * names a readable one. The switch comes from the highest layer that
133
+ * either sets it (`--dont-ask`, KISO_DONT_ASK, "dontAsk": true|false) or
134
+ * WON THE TIER with the old name `dontAsk` — an old name's switch counts
135
+ * only where that old name decided the tier. So every combination of old
136
+ * spellings resolves as it did when dontAsk was a tier: a user config
137
+ * saying "dontAsk" under `--mode bypass` still asks, because --mode won.
138
+ */
139
+ export function resolveModeLayers(layers) {
140
+ const order = [
141
+ ["flag", layers.flag],
142
+ ["env", layers.env],
143
+ ["project", layers.project],
144
+ ["user", layers.user],
145
+ ];
146
+ let tier = { setting: { mode: "default" }, from: "default" };
147
+ for (const [from, layer] of order) {
148
+ const setting = layer?.mode === undefined ? undefined : parseMode(layer.mode);
149
+ if (setting !== undefined) {
150
+ tier = { setting, from };
151
+ break;
152
+ }
153
+ }
154
+ for (const [from, layer] of order) {
155
+ if (layer?.dontAsk !== undefined)
156
+ return { mode: tier.setting.mode, dontAsk: layer.dontAsk ? "on" : "off", from: { mode: tier.from, dontAsk: from } };
157
+ if (from === tier.from && tier.setting.dontAsk === true)
158
+ return { mode: tier.setting.mode, dontAsk: "old-name", from: { mode: tier.from, dontAsk: from } };
159
+ }
160
+ return { mode: tier.setting.mode, dontAsk: "off", from: { mode: tier.from, dontAsk: "default" } };
56
161
  }
57
- /** The startup mode from env: KISO_MODE, or undefined when unset (the
58
- * config layer's mode then applies — merge round B; the --mode flag is
59
- * applied by main before the first createCodingAgent and wins over everything). */
60
- export function modeFromEnv() {
61
- const raw = process.env.KISO_MODE;
62
- return MODES.find((x) => x === raw);
162
+ /** Put a resolved state in force — startup, and again once the project
163
+ * config is known. */
164
+ export function applyModeState(s) {
165
+ current = s.mode;
166
+ dontAsk = s.dontAsk;
63
167
  }
64
168
  /** The per-tier verdict for a tool call — only when this tier is current. */
65
169
  function tierVerdict(tier, call, workspaceRoot) {
@@ -68,14 +172,13 @@ function tierVerdict(tier, call, workspaceRoot) {
68
172
  switch (tier) {
69
173
  case "manual":
70
174
  return { action: "ask" }; // every tool asks
71
- // dontAsk decides exactly as default does. What makes it dontAsk is
72
- // what happens to the chain's final ASK: the CLI denies it at the
73
- // ask endpoint instead of asking (chat.ts). Not a tier deny — that
74
- // would outrank every allow, and a saved allow and the read-only
75
- // shell allow must still allow.
175
+ // The don't-ask switch changes no tier's decision. What it changes is
176
+ // what happens to the chain's final ASK: the CLI denies it at the ask
177
+ // endpoint instead of asking (chat.ts). Not a tier deny — that would
178
+ // outrank every allow, and a saved allow and the read-only shell allow
179
+ // must still allow.
76
180
  case "default":
77
- case "dontAsk":
78
- if (READ_TOOLS.has(call.name))
181
+ if (READ_TOOLS.has(call.name) || STOP_TOOLS.has(call.name))
79
182
  return { action: "allow" };
80
183
  if (call.name === "write_file" || call.name === "edit_file" || call.name === "shell")
81
184
  return { action: "ask" };
@@ -86,7 +189,7 @@ function tierVerdict(tier, call, workspaceRoot) {
86
189
  // finding: "allow"-as-no-opinion auto-approved it).
87
190
  return { action: "abstain" };
88
191
  case "accept-edits":
89
- if (READ_TOOLS.has(call.name))
192
+ if (READ_TOOLS.has(call.name) || STOP_TOOLS.has(call.name))
90
193
  return { action: "allow" };
91
194
  // 0.40.0: a write into .git/ or .kiso/ asks even here — both hold
92
195
  // configuration that runs (protected-writes.ts)
@@ -96,22 +199,22 @@ function tierVerdict(tier, call, workspaceRoot) {
96
199
  return { action: "ask" };
97
200
  return { action: "abstain" }; // see "default"
98
201
  case "plan":
99
- if (READ_TOOLS.has(call.name))
202
+ if (READ_TOOLS.has(call.name) || STOP_TOOLS.has(call.name))
100
203
  return { action: "allow" };
101
204
  return { action: "deny", reason: "plan mode: read-only" };
102
- case "bypass":
205
+ case "full-access":
103
206
  return { action: "allow" }; // everything — a REAL allow, never an abstain
104
207
  }
105
208
  }
106
- /** The six built-in mode tiers as chain extensions — named "mode:<tier>"
209
+ /** The built-in mode tiers as chain extensions — named "mode:<tier>"
107
210
  * so the runtime's decidedBy records exactly that (the runtime derives
108
211
  * approvalPolicies from extensions[].approvals, tagging each with the
109
212
  * extension name). The CURRENT tier is first: an all-allow chain records
110
213
  * decidedBy = the FIRST SPEAKER, so an auto-allow under the startup mode
111
214
  * names that mode honestly. Order never affects verdicts — the chain is
112
215
  * deny>allow>ask over the SPEAKING verdicts (abstain = no opinion), so a
113
- * user extension's deny wins over any mode tier, bypass included (the
114
- * monotonicity e2e pins it). */
216
+ * user extension's deny wins over any mode tier, full-access included
217
+ * (the monotonicity e2e pins it). */
115
218
  export function modeExtensions(workspaceRoot = () => process.cwd()) {
116
219
  return [...MODES.filter((m) => m === current), ...MODES.filter((m) => m !== current)].map((m) => ({
117
220
  name: `mode:${m}`,
@@ -35,10 +35,10 @@
35
35
  * guarantee is the sandbox, which stays post-launch.
36
36
  */
37
37
  import { readdirSync, realpathSync, statSync } from "node:fs";
38
- import { basename, dirname, resolve } from "node:path";
38
+ import { basename } from "node:path";
39
39
  import { protectedIdentity, PROTECTED_REFUSAL } from "@vincemakes/kiso-tools-node";
40
40
  import { innerLines, unwrap } from "./floor.js";
41
- import { parseShellLooseChecked } from "./shell-words.js";
41
+ import { hostDialect, msysPath, parseShellLooseChecked, resolveShellPath } from "./shell-words.js";
42
42
  const ABSTAIN = { action: "abstain" };
43
43
  /** How deep `sh -c`/`eval` lines are followed, and how many are read. */
44
44
  const MAX_INNER_DEPTH = 16;
@@ -104,7 +104,8 @@ function globRegex(glob) {
104
104
  // case-folded: on a disk that folds case, `.KISO/*` is the same directory
105
105
  return new RegExp(`^${re}$`, "i");
106
106
  }
107
- function probeFor(id) {
107
+ function probeFor(id, { msys, path }) {
108
+ const { basename, dirname, join } = path;
108
109
  const files = new Set();
109
110
  const dirs = new Set();
110
111
  const targets = [];
@@ -115,7 +116,7 @@ function probeFor(id) {
115
116
  try {
116
117
  for (const name of readdirSync(dir))
117
118
  if (name.startsWith(`${base}.`))
118
- targets.push(`${dir}/${name}`);
119
+ targets.push(msys ? join(dir, name) : `${dir}/${name}`);
119
120
  }
120
121
  catch {
121
122
  // no directory yet — nothing in it to serve
@@ -139,8 +140,10 @@ function probeFor(id) {
139
140
  export function protectedShellCheck(line, workspaceRoot, id, env) {
140
141
  if (id.paths.length === 0)
141
142
  return { hit: false };
142
- const probe = probeFor(id);
143
- const names = id.paths.map((p) => basename(p).toLowerCase());
143
+ const dialect = hostDialect();
144
+ const P = dialect.path;
145
+ const probe = probeFor(id, dialect);
146
+ const names = id.paths.map((p) => P.basename(p).toLowerCase());
144
147
  const paths = id.paths.map((p) => p.toLowerCase());
145
148
  const namesProtected = (t) => {
146
149
  const low = t.toLowerCase();
@@ -168,7 +171,17 @@ export function protectedShellCheck(line, workspaceRoot, id, env) {
168
171
  seenStat.set(p, st);
169
172
  return st;
170
173
  };
171
- const at = (cwd, t) => (t.startsWith("/") ? t : `${cwd}/${t}`);
174
+ // on Windows the system normalizes a path lexically before it opens it,
175
+ // so the lexical resolution is what a command opens there
176
+ const at = (cwd, t) => (dialect.msys ? P.resolve(cwd, t) : t.startsWith("/") ? t : `${cwd}/${t}`);
177
+ /** Windows P2: a word as Git Bash's command sees the path — or null
178
+ * when it cannot be read (only its spelling can be checked). */
179
+ const native = (t) => {
180
+ if (!dialect.msys)
181
+ return t;
182
+ const read = msysPath(t);
183
+ return "opaque" in read ? null : read.native;
184
+ };
172
185
  const regexes = new Map();
173
186
  const realOf = new Map();
174
187
  /** Whether a word's last component is a protected file's name, or one
@@ -183,12 +196,15 @@ export function protectedShellCheck(line, workspaceRoot, id, env) {
183
196
  return out.replace(/\$\{KISO_HOME\}|\$KISO_HOME(?![A-Za-z0-9_])/g, env.kisoHome);
184
197
  };
185
198
  /** One literal-or-glob path text, read from each candidate directory. */
186
- const pathHits = (t, s) => {
187
- if (t === "")
199
+ const pathHits = (word, s) => {
200
+ if (word === "")
188
201
  return false;
189
- if (/[$`]/.test(t))
190
- return namesProtected(t);
191
- if (s.lossy && !t.startsWith("/") && namesProtected(t))
202
+ if (/[$`]/.test(word))
203
+ return namesProtected(word);
204
+ const t = native(word);
205
+ if (t === null)
206
+ return namesProtected(word);
207
+ if (s.lossy && !P.isAbsolute(t) && namesProtected(t))
192
208
  return true;
193
209
  const glob = /[*?[]/.test(t);
194
210
  for (const cwd of s.cwds) {
@@ -198,13 +214,31 @@ export function protectedShellCheck(line, workspaceRoot, id, env) {
198
214
  // not exist yet) — only when the last component could be the
199
215
  // file's name — then by inode
200
216
  if (named(t)) {
201
- const spelled = resolve(cwd, t).toLowerCase();
217
+ const spelled = P.resolve(cwd, t).toLowerCase();
202
218
  if (paths.some((pf) => spelled === pf || spelled.startsWith(`${pf}.`)))
203
219
  return true;
204
220
  }
205
221
  const st = statAt(at(cwd, t));
206
222
  if (st?.isFile() === true && probe.files.has(inode(st)))
207
223
  return true;
224
+ if (dialect.msys && t.split("/").includes("..")) {
225
+ // Windows: the lexical reading above, and — for a `..`, where
226
+ // they can differ — the real-path one; a `..` after a link the
227
+ // two read apart falls back to names. One more reading charged.
228
+ charge();
229
+ const real = resolveShellPath(cwd, cwd, t, dialect);
230
+ if (real.opaque !== undefined) {
231
+ if (namesProtected(t))
232
+ return true;
233
+ continue;
234
+ }
235
+ const low = real.canonical.toLowerCase();
236
+ if (paths.some((pf) => low === pf || low.startsWith(`${pf}.`)))
237
+ return true;
238
+ const rst = statAt(real.canonical);
239
+ if (rst?.isFile() === true && probe.files.has(inode(rst)))
240
+ return true;
241
+ }
208
242
  continue;
209
243
  }
210
244
  const g = t.search(/[*?[]/);
@@ -232,11 +266,14 @@ export function protectedShellCheck(line, workspaceRoot, id, env) {
232
266
  }
233
267
  realOf.set(dirText, real);
234
268
  }
235
- const pattern = `${real.endsWith("/") ? real : `${real}/`}${rest}`;
269
+ // Windows: one separator, one case, on both sides of the match
270
+ const fold = (p) => (dialect.msys ? p.replace(/\\/g, "/").toLowerCase() : p);
271
+ const dirReal = fold(real);
272
+ const pattern = `${dirReal.endsWith("/") ? dirReal : `${dirReal}/`}${dialect.msys ? rest.toLowerCase() : rest}`;
236
273
  let re = regexes.get(pattern);
237
274
  if (re === undefined)
238
275
  regexes.set(pattern, (re = globRegex(pattern)));
239
- if (probe.targets.some((p) => re.test(p)))
276
+ if (probe.targets.some((p) => re.test(fold(p))))
240
277
  return true;
241
278
  }
242
279
  return false;
@@ -255,8 +292,11 @@ export function protectedShellCheck(line, workspaceRoot, id, env) {
255
292
  /** A directory holding a protected file — the file's own, or any
256
293
  * directory above it — by inode. */
257
294
  const holds = (w, s) => {
258
- const t = expand(w.text, w.tilde);
259
- if (t === "" || /[$`*?[]/.test(t) || (w.tilde && /^~[^/]/.test(w.text)))
295
+ const spelled = expand(w.text, w.tilde);
296
+ if (spelled === "" || /[$`*?[]/.test(spelled) || (w.tilde && /^~[^/]/.test(w.text)))
297
+ return false;
298
+ const t = native(spelled);
299
+ if (t === null)
260
300
  return false;
261
301
  return s.cwds.some((cwd) => {
262
302
  charge();
@@ -334,8 +374,9 @@ export function protectedShellCheck(line, workspaceRoot, id, env) {
334
374
  else if (to === undefined)
335
375
  s.pending = [env.home];
336
376
  else {
337
- const t = expand(to.text, to.tilde);
338
- if (/[$`*?[]/.test(t) || (to.tilde && /^~[^/]/.test(to.text))) {
377
+ const spelled = expand(to.text, to.tilde);
378
+ const t = native(spelled);
379
+ if (t === null || /[$`*?[]/.test(spelled) || (to.tilde && /^~[^/]/.test(to.text))) {
339
380
  s.lossy = true;
340
381
  s.pending = cap(s, [env.home, ...s.cwds]);
341
382
  }
@@ -12,8 +12,8 @@
12
12
  * So a write_file or edit_file whose path — as written, or once symlinks
13
13
  * are followed — passes through a `.git` or `.kiso` directory asks in
14
14
  * every asking tier: accept-edits' allow does not cover it, and neither
15
- * does a saved allow. dontAsk therefore denies it; plan denies it anyway;
16
- * bypass runs it, as bypass's shell could.
15
+ * does a saved allow. With the don't-ask switch on it is therefore denied;
16
+ * plan denies it anyway; full-access runs it, as full-access's shell could.
17
17
  */
18
18
  import type { PolicyCall } from "@vincemakes/kiso-core";
19
19
  import type { KisoExtension } from "@vincemakes/kiso-runtime";
@@ -12,8 +12,8 @@
12
12
  * So a write_file or edit_file whose path — as written, or once symlinks
13
13
  * are followed — passes through a `.git` or `.kiso` directory asks in
14
14
  * every asking tier: accept-edits' allow does not cover it, and neither
15
- * does a saved allow. dontAsk therefore denies it; plan denies it anyway;
16
- * bypass runs it, as bypass's shell could.
15
+ * does a saved allow. With the don't-ask switch on it is therefore denied;
16
+ * plan denies it anyway; full-access runs it, as full-access's shell could.
17
17
  */
18
18
  import { relative, sep } from "node:path";
19
19
  import { resolveShellPath } from "./shell-words.js";
@@ -32,24 +32,26 @@
32
32
  */
33
33
  import { statSync } from "node:fs";
34
34
  import { homedir } from "node:os";
35
- import { basename, isAbsolute, join, relative } from "node:path";
35
+ import { join } from "node:path";
36
36
  import { isCredentialName, isCredentialPath } from "@vincemakes/kiso-tools-node";
37
37
  import { getMode } from "./mode.js";
38
- import { HOME_SUBTREES, parseShell, realCase, resolveShellPath } from "./shell-words.js";
38
+ import { homeSubtrees, hostDialect, parseShell, realCase, resolveShellPath } from "./shell-words.js";
39
39
  // ── the predicates ──────────────────────────────────────────────────────
40
40
  function scope(ctx, word) {
41
41
  if (word === "-")
42
42
  return null; // stdin
43
43
  for (const cwd of ctx.cwds) {
44
- const { canonical, inside } = resolveShellPath(ctx.root, cwd, word);
44
+ const { canonical, inside, opaque } = resolveShellPath(ctx.root, cwd, word);
45
+ if (opaque !== undefined)
46
+ return `${word} is ${opaque}`;
45
47
  if (!inside)
46
48
  return `${word} is outside the workspace`;
47
49
  // B4: under a protected directory NOTHING is read or listed unasked —
48
50
  // kiso's own home, and ~/.ssh ~/.config ~/.aws ~/.gnupg ~/.kiso when
49
51
  // the workspace is the home directory. Over-asking is an ask.
50
52
  for (const p of ctx.protectedRoots) {
51
- const rel = relative(p, canonical);
52
- if (rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)))
53
+ const rel = hostDialect().path.relative(p, canonical);
54
+ if (rel === "" || (!rel.startsWith("..") && !hostDialect().path.isAbsolute(rel)))
53
55
  return `${word} is inside a protected directory (${p})`;
54
56
  }
55
57
  }
@@ -59,7 +61,7 @@ function scope(ctx, word) {
59
61
  * the file tools ask ONE rule — names (`isCredentialName`) and the
60
62
  * directory-scoped paths (`isCredentialPath`) — on the canonical path. */
61
63
  function shellCredential(canonical) {
62
- return isCredentialName(basename(canonical).toLowerCase()) || isCredentialPath(canonical);
64
+ return isCredentialName(hostDialect().path.basename(canonical).toLowerCase()) || isCredentialPath(canonical);
63
65
  }
64
66
  function content(ctx, word) {
65
67
  const s = scope(ctx, word);
@@ -69,11 +71,11 @@ function content(ctx, word) {
69
71
  return null;
70
72
  // B2: the disk is case-insensitive and the names are not — `.ENV` and
71
73
  // `ID_RSA` are `.env` and `id_rsa` to the file system, so to the rule
72
- if (isCredentialName(basename(word).toLowerCase()))
74
+ if (isCredentialName(hostDialect().path.basename(word).toLowerCase()))
73
75
  return `${word} is a credential file`;
74
76
  for (const cwd of ctx.cwds) {
75
77
  const { canonical } = resolveShellPath(ctx.root, cwd, word);
76
- if (isCredentialName(basename(canonical).toLowerCase()) || shellCredential(canonical))
78
+ if (isCredentialName(hostDialect().path.basename(canonical).toLowerCase()) || shellCredential(canonical))
77
79
  return `${word} leads to a credential file`;
78
80
  }
79
81
  return null;
@@ -552,11 +554,11 @@ export function classifyReadOnly(commandLine, workspaceRoot, protectedRoots = []
552
554
  }
553
555
  return { allow: true };
554
556
  }
555
- /** The modes in which a provable read runs unasked. dontAsk is one: an
556
- * allow is what it still honours. Manual promises that every tool asks;
557
- * plan denies shell outright; bypass allows everything already — this
558
- * member has nothing to add to any of them. */
559
- const ALLOWING_MODES = new Set(["default", "accept-edits", "dontAsk"]);
557
+ /** The modes in which a provable read runs unasked — with the don't-ask
558
+ * switch on too: an allow is what it still honours. Manual promises that
559
+ * every tool asks; plan denies shell outright; full-access allows
560
+ * everything already — this member has nothing to add to any of them. */
561
+ const ALLOWING_MODES = new Set(["default", "accept-edits"]);
560
562
  const ABSTAIN = { action: "abstain" };
561
563
  /**
562
564
  * The chain member. ALLOW or ABSTAIN, never anything else: an allow here
@@ -577,7 +579,7 @@ export function readOnlyShellExtension(roots) {
577
579
  if (typeof command !== "string")
578
580
  return ABSTAIN;
579
581
  const { workspaceRoot, excludeRoots } = roots();
580
- const protectedRoots = [...excludeRoots, ...HOME_SUBTREES.map((s) => join(homedir(), s))];
582
+ const protectedRoots = [...excludeRoots, ...homeSubtrees().map((s) => join(homedir(), s))];
581
583
  return classifyReadOnly(command, workspaceRoot, protectedRoots).allow ? { action: "allow" } : ABSTAIN;
582
584
  },
583
585
  },
package/dist/replay.js CHANGED
@@ -20,6 +20,7 @@
20
20
  * the viewer — then any uncovered earlier turns folded as above.
21
21
  */
22
22
  import { echoText } from "@vincemakes/kiso-tui-cells/render";
23
+ import { taskNoticeRow } from "./task-notice.js";
23
24
  /** How many turns stay on screen in full. */
24
25
  const FULL_TURNS = 2;
25
26
  /** A user turn's opening line, or null when this input does not open one
@@ -61,6 +62,8 @@ function replayTurn(body, turn) {
61
62
  const ask = askOf(e);
62
63
  if (ask !== null)
63
64
  body.userLine(ask);
65
+ else if (e.via?.kind === "tasks")
66
+ body.notice(taskNoticeRow(e.via.items));
64
67
  else if (e.source === "system") {
65
68
  body.notice("verification pass");
66
69
  body.notice(` ${typeof e.content === "string" ? e.content : ""}`);
package/dist/resume.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * moved verbatim from index.ts.
4
4
  */
5
5
  import { idleStatus, runningStatus } from "@vincemakes/kiso-tui";
6
- import { getMode } from "./mode.js";
6
+ import { modeDisplay } from "./mode.js";
7
7
  import { agentModel, dock, retryOnRow, floorOn } from "./state.js";
8
8
  import { pendingAsk, resolveUncertains } from "./trust-ui.js";
9
9
  import { failOnFauxExhaustion } from "./faux-glue.js";
@@ -37,7 +37,7 @@ export async function resume(session, prompt, faux, input) {
37
37
  // same budget. It has no meter, so nothing here can be dropped that
38
38
  // was not already at risk of being cut.
39
39
  if (dock.active)
40
- dock.setStatus(idleStatus(getMode(), agentModel, estimateCtxRatio(session), undefined, process.stdout.columns > 0 ? process.stdout.columns : 80, !floorOn));
40
+ dock.setStatus(idleStatus(modeDisplay(), agentModel, estimateCtxRatio(session), undefined, process.stdout.columns > 0 ? process.stdout.columns : 80, !floorOn));
41
41
  };
42
42
  const withRun = async (run) => {
43
43
  currentRun = run;
@@ -20,8 +20,11 @@ export interface SettingsInput {
20
20
  /** the --mode / --model values on the command line, when given */
21
21
  readonly modeFlag?: string;
22
22
  readonly modelFlag?: string;
23
+ /** whether --dont-ask was on the command line */
24
+ readonly dontAskFlag?: boolean;
23
25
  /** what is live now */
24
26
  readonly mode: string;
27
+ readonly dontAsk?: boolean;
25
28
  readonly model: {
26
29
  readonly label: string;
27
30
  readonly profile: string | null;
package/dist/settings.js CHANGED
@@ -1,18 +1,47 @@
1
+ import { envModeLayer, parseMode, resolveModeLayers } from "./mode.js";
2
+ const MODE_LAYER = { flag: "--mode", env: "env KISO_MODE", project: "project config", user: "user config", default: "default" };
3
+ /** What a layer wrote for the tier — an old name is named, so a person
4
+ * learns the new one from the row that shows it. */
5
+ function writtenMode(i, source) {
6
+ return source === "flag" ? i.modeFlag : source === "env" ? i.env.KISO_MODE : source === "project" ? i.project?.mode : source === "user" ? i.user?.mode : undefined;
7
+ }
8
+ function modeFrom(i, source) {
9
+ const w = writtenMode(i, source);
10
+ return w === "bypass" || w === "dontAsk" ? `${MODE_LAYER[source]} (written "${w}")` : MODE_LAYER[source];
11
+ }
12
+ /** The switch's layer: its own key there, or — the resolution's other
13
+ * way in — that layer's tier written as the old name dontAsk. */
14
+ function dontAskFrom(i, source) {
15
+ if (source === "default")
16
+ return "default";
17
+ const own = source === "flag" ? i.dontAskFlag === true : source === "env" ? envModeLayer(i.env).dontAsk !== undefined : source === "project" ? i.project?.dontAsk !== undefined : i.user?.dontAsk !== undefined;
18
+ if (own)
19
+ return source === "flag" ? "--dont-ask" : source === "env" ? "env KISO_DONT_ASK" : MODE_LAYER[source];
20
+ return `${MODE_LAYER[source]} (written "dontAsk")`;
21
+ }
22
+ function modeLayers(i) {
23
+ return {
24
+ flag: { ...(i.modeFlag !== undefined ? { mode: i.modeFlag } : {}), ...(i.dontAskFlag === true ? { dontAsk: true } : {}) },
25
+ env: envModeLayer(i.env),
26
+ project: i.project,
27
+ user: i.user,
28
+ };
29
+ }
1
30
  function modeRow(i) {
2
31
  const change = "/mode or shift+tab; \"mode\" in ~/.kiso/config.json";
3
- const from = i.modeFlag === i.mode
4
- ? "--mode"
5
- : i.env.KISO_MODE === i.mode
6
- ? "env KISO_MODE"
7
- : i.project?.mode === i.mode
8
- ? "project config"
9
- : i.user?.mode === i.mode
10
- ? "user config"
11
- : i.mode === "default"
12
- ? "default"
13
- : "set in this session";
32
+ // the layers decided at start-up; a live tier they do not explain was
33
+ // set in this session
34
+ const resolved = resolveModeLayers(modeLayers(i));
35
+ const from = parseMode(i.mode)?.mode === resolved.mode ? modeFrom(i, resolved.from.mode) : "set in this session";
14
36
  return { name: "mode", value: i.mode, from, change };
15
37
  }
38
+ function dontAskRow(i) {
39
+ const change = "/dont-ask; --dont-ask; \"dontAsk\": true in ~/.kiso/config.json";
40
+ const on = i.dontAsk === true;
41
+ const resolved = resolveModeLayers(modeLayers(i));
42
+ const from = (resolved.dontAsk !== "off") === on ? dontAskFrom(i, resolved.from.dontAsk) : "set in this session";
43
+ return { name: "don't ask", value: on ? "on — what would ask is refused" : "off", from, change };
44
+ }
16
45
  function modelRow(i) {
17
46
  const change = "/model; \"model\" in ~/.kiso/config.json";
18
47
  const p = i.model.profile;
@@ -50,6 +79,7 @@ export function settingsRows(i) {
50
79
  const rows = [
51
80
  modelRow(i),
52
81
  modeRow(i),
82
+ dontAskRow(i),
53
83
  themeRow(i),
54
84
  {
55
85
  name: "floor",