@czottmann/pi-automode 1.11.0 → 1.12.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.
@@ -1,35 +1,119 @@
1
- import { lstatSync, readlinkSync, realpathSync } from "node:fs";
1
+ import {
2
+ accessSync,
3
+ constants,
4
+ lstatSync,
5
+ readlinkSync,
6
+ realpathSync,
7
+ } from "node:fs";
2
8
  import {
3
9
  basename,
4
10
  dirname,
5
11
  isAbsolute,
12
+ join,
6
13
  normalize,
7
14
  relative,
8
15
  resolve,
9
16
  } from "node:path";
17
+ import { fileURLToPath } from "node:url";
10
18
  import { HOME, PATH_BEARING_TOOLS, PROFILE_FILES } from "./constants.ts";
11
19
 
12
- function stripLeadingAt(value: string): string {
13
- return value.startsWith("@") ? value.slice(1) : value;
20
+ const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
21
+
22
+ /** Convert Git Bash, MSYS, Cygwin, and WSL drive paths for Windows APIs. */
23
+ function normalizeWindowsShellPath(path: string): string {
24
+ if (
25
+ process.platform !== "win32" ||
26
+ !path.startsWith("/") ||
27
+ path.startsWith("//") ||
28
+ path.includes("\\")
29
+ ) {
30
+ return path;
31
+ }
32
+ const match = path.match(/^\/(?:mnt\/|cygdrive\/)?([a-z])(?:\/(.*))?$/i);
33
+ if (!match) return path;
34
+ const suffix = match[2]?.replaceAll("/", "\\");
35
+ return `${match[1]?.toUpperCase()}:\\${suffix ?? ""}`;
36
+ }
37
+
38
+ /** Mirror Pi's path normalization options. */
39
+ function normalizeInputPath(
40
+ value: string,
41
+ options: { normalizeUnicodeSpaces?: boolean; stripAtPrefix?: boolean } = {},
42
+ ): string {
43
+ let normalized = options.normalizeUnicodeSpaces
44
+ ? value.replace(UNICODE_SPACES, " ")
45
+ : value;
46
+ if (options.stripAtPrefix && normalized.startsWith("@")) {
47
+ normalized = normalized.slice(1);
48
+ }
49
+ normalized = normalizeWindowsShellPath(normalized);
50
+ if (normalized === "~") return HOME;
51
+ if (
52
+ normalized.startsWith("~/") ||
53
+ (process.platform === "win32" && normalized.startsWith("~\\"))
54
+ ) {
55
+ return join(HOME, normalized.slice(2));
56
+ }
57
+ if (/^file:\/\//.test(normalized)) return fileURLToPath(normalized);
58
+ return normalized;
14
59
  }
15
60
 
16
61
  export function resolveInputPath(
17
62
  cwd: string,
18
63
  value: unknown,
19
64
  ): string | undefined {
20
- if (typeof value !== "string" || value.trim() === "") return undefined;
21
- const raw = stripLeadingAt(value.trim());
22
- return isAbsolute(raw) ? resolve(raw) : resolve(cwd, raw);
65
+ if (typeof value !== "string") return undefined;
66
+ const normalized = normalizeInputPath(value, {
67
+ normalizeUnicodeSpaces: true,
68
+ stripAtPrefix: true,
69
+ });
70
+ return isAbsolute(normalized)
71
+ ? resolve(normalized)
72
+ : resolve(normalizeInputPath(cwd), normalized);
23
73
  }
24
74
 
25
- /** The target path a file tool operates on, from `input.path` (or undefined). */
75
+ function existingReadVariant(path: string): string {
76
+ const candidates = [
77
+ path,
78
+ path.replace(/ (AM|PM)\./gi, "\u202F$1."),
79
+ path.normalize("NFD"),
80
+ path.replace(/'/g, "\u2019"),
81
+ path.normalize("NFD").replace(/'/g, "\u2019"),
82
+ ];
83
+ for (const candidate of candidates) {
84
+ try {
85
+ accessSync(candidate, constants.F_OK);
86
+ return candidate;
87
+ } catch {
88
+ // Try the next Pi-compatible read fallback.
89
+ }
90
+ }
91
+ return path;
92
+ }
93
+
94
+ /** Resolve the path that the named Pi file tool will operate on. */
95
+ export function resolveToolInputPath(
96
+ toolName: string,
97
+ cwd: string,
98
+ value: unknown,
99
+ ): string | undefined {
100
+ const resolved = resolveInputPath(cwd, value);
101
+ if (!resolved || toolName !== "read") return resolved;
102
+ return existingReadVariant(resolved);
103
+ }
104
+
105
+ /** The effective target path of a file tool, including Pi's `.` defaults. */
26
106
  export function extractInputPath(
27
107
  toolName: string,
28
108
  input: Record<string, unknown>,
29
109
  ): string | undefined {
30
110
  if (!PATH_BEARING_TOOLS.has(toolName)) return undefined;
31
111
  const value = input.path;
32
- return typeof value === "string" && value.trim() !== "" ? value : undefined;
112
+ if (typeof value === "string" && value !== "") return value;
113
+ if (toolName === "grep" || toolName === "find" || toolName === "ls") {
114
+ return ".";
115
+ }
116
+ return typeof value === "string" ? value : undefined;
33
117
  }
34
118
 
35
119
  /** Expand a leading `~`, `$HOME`, or `${HOME}` in a path-denial pattern. */
@@ -2,9 +2,31 @@ import type { ToolPattern } from "./types.ts";
2
2
  import {
3
3
  expandHomePattern,
4
4
  normalizePathForMatch,
5
- resolveInputPath,
5
+ resolvePathForPolicy,
6
+ resolveToolInputPath,
6
7
  } from "./paths.ts";
7
8
 
9
+ export const MAX_WILDCARD_PATTERN_LENGTH = 4096;
10
+ export const MAX_WILDCARD_INPUT_LENGTH = 1024 * 1024;
11
+
12
+ /** Preserve the previous non-Unicode RegExp `/i` case-equivalence rules. */
13
+ function canonicalizeCase(value: string): string {
14
+ let canonical = "";
15
+ for (let index = 0; index < value.length; index += 1) {
16
+ const character = value[index] ?? "";
17
+ const uppercase = character.toUpperCase();
18
+ if (
19
+ uppercase.length !== 1 ||
20
+ (character.charCodeAt(0) >= 128 && uppercase.charCodeAt(0) < 128)
21
+ ) {
22
+ canonical += character;
23
+ } else {
24
+ canonical += uppercase;
25
+ }
26
+ }
27
+ return canonical;
28
+ }
29
+
8
30
  function normalizeToolName(name: string): string {
9
31
  const lower = name.trim().replace(/^@/, "").toLowerCase();
10
32
  const aliases: Record<string, string> = {
@@ -40,11 +62,108 @@ export function parseToolPattern(value: unknown): ToolPattern | undefined {
40
62
  };
41
63
  }
42
64
 
43
- function wildcardToRegExp(pattern: string): RegExp {
44
- const escaped = pattern
45
- .replace(/[.+^${}()|[\]\\]/g, "\\$&")
46
- .replace(/\*/g, ".*");
47
- return new RegExp(`^${escaped}$`, "i");
65
+ function literalPrefixTable(value: string): number[] {
66
+ const table = new Array<number>(value.length).fill(0);
67
+ let prefixLength = 0;
68
+ for (let index = 1; index < value.length; index += 1) {
69
+ while (
70
+ prefixLength > 0 && value[index] !== value[prefixLength]
71
+ ) {
72
+ prefixLength = table[prefixLength - 1] ?? 0;
73
+ }
74
+ if (value[index] === value[prefixLength]) prefixLength += 1;
75
+ table[index] = prefixLength;
76
+ }
77
+ return table;
78
+ }
79
+
80
+ function findLiteral(
81
+ value: string,
82
+ literal: string,
83
+ start: number,
84
+ end: number,
85
+ ): number {
86
+ const prefixTable = literalPrefixTable(literal);
87
+ let matched = 0;
88
+ for (let index = start; index < end; index += 1) {
89
+ while (matched > 0 && value[index] !== literal[matched]) {
90
+ matched = prefixTable[matched - 1] ?? 0;
91
+ }
92
+ if (value[index] === literal[matched]) matched += 1;
93
+ if (matched === literal.length) return index - literal.length + 1;
94
+ }
95
+ return -1;
96
+ }
97
+
98
+ export type WildcardOverflowPolicy = "match" | "no-match";
99
+
100
+ /**
101
+ * Match a case-insensitive `*` wildcard pattern in linear time.
102
+ *
103
+ * `*` matches zero or more characters, including newlines and path separators.
104
+ * Denial callers use `match` for over-limit values so they fail closed. Allow
105
+ * callers use `no-match` so an oversized input cannot broaden an allow rule.
106
+ */
107
+ export function matchesWildcardPattern(
108
+ pattern: string,
109
+ value: string,
110
+ overflowPolicy: WildcardOverflowPolicy = "match",
111
+ ): boolean {
112
+ if (
113
+ pattern.length > MAX_WILDCARD_PATTERN_LENGTH ||
114
+ value.length > MAX_WILDCARD_INPUT_LENGTH
115
+ ) {
116
+ return overflowPolicy === "match";
117
+ }
118
+
119
+ const normalizedPattern = canonicalizeCase(pattern);
120
+ const normalizedValue = canonicalizeCase(value);
121
+ if (!normalizedPattern.includes("*")) {
122
+ return normalizedPattern === normalizedValue;
123
+ }
124
+
125
+ const startsWithWildcard = normalizedPattern.startsWith("*");
126
+ const endsWithWildcard = normalizedPattern.endsWith("*");
127
+ const literals = normalizedPattern.split("*").filter(Boolean);
128
+ if (literals.length === 0) return true;
129
+
130
+ let literalIndex = 0;
131
+ let valueIndex = 0;
132
+ let lastLiteralIndex = literals.length;
133
+
134
+ if (!startsWithWildcard) {
135
+ const prefix = literals[0] ?? "";
136
+ if (!normalizedValue.startsWith(prefix)) return false;
137
+ valueIndex = prefix.length;
138
+ literalIndex = 1;
139
+ }
140
+
141
+ let searchEnd = normalizedValue.length;
142
+ if (!endsWithWildcard) {
143
+ const suffix = literals[literals.length - 1] ?? "";
144
+ searchEnd -= suffix.length;
145
+ if (
146
+ searchEnd < valueIndex ||
147
+ !normalizedValue.endsWith(suffix)
148
+ ) {
149
+ return false;
150
+ }
151
+ lastLiteralIndex -= 1;
152
+ }
153
+
154
+ for (; literalIndex < lastLiteralIndex; literalIndex += 1) {
155
+ const literal = literals[literalIndex] ?? "";
156
+ const found = findLiteral(
157
+ normalizedValue,
158
+ literal,
159
+ valueIndex,
160
+ searchEnd,
161
+ );
162
+ if (found < 0) return false;
163
+ valueIndex = found + literal.length;
164
+ }
165
+
166
+ return true;
48
167
  }
49
168
 
50
169
  function getPrimaryArgument(
@@ -60,7 +179,7 @@ function getPrimaryArgument(
60
179
  typeof input.path === "string"
61
180
  ) {
62
181
  return normalizePathForMatch(
63
- resolveInputPath(cwd, input.path) ?? input.path,
182
+ resolveToolInputPath(toolName, cwd, input.path) ?? input.path,
64
183
  cwd,
65
184
  );
66
185
  }
@@ -72,7 +191,7 @@ function getPrimaryArgument(
72
191
  typeof input.path === "string"
73
192
  ) {
74
193
  return normalizePathForMatch(
75
- resolveInputPath(cwd, input.path) ?? input.path,
194
+ resolveToolInputPath(toolName, cwd, input.path) ?? input.path,
76
195
  cwd,
77
196
  );
78
197
  }
@@ -89,10 +208,80 @@ export function matchesDeniedPath(
89
208
  resolvedPath: string,
90
209
  deniedPaths: string[],
91
210
  ): boolean {
92
- const normalized = resolvedPath.replace(/\\/g, "/");
211
+ const normalized = resolvedPath.replace(/\\/g, "/").normalize("NFC");
212
+ return deniedPaths.some((pattern) =>
213
+ deniedPatternVariants(pattern).some((variant) =>
214
+ matchesWildcardPattern(variant.normalize("NFC"), normalized)
215
+ )
216
+ );
217
+ }
218
+
219
+ function deniedPatternVariants(pattern: string): string[] {
220
+ const expanded = expandHomePattern(pattern).replace(/\\/g, "/");
221
+ const wildcardIndex = expanded.indexOf("*");
222
+ if (wildcardIndex === -1) {
223
+ const canonical = resolvePathForPolicy(expanded)?.replace(/\\/g, "/");
224
+ return canonical && canonical !== expanded
225
+ ? [expanded, canonical]
226
+ : [expanded];
227
+ }
228
+
229
+ const fixedPrefix = expanded.slice(0, wildcardIndex);
230
+ const lastSlash = fixedPrefix.lastIndexOf("/");
231
+ if (lastSlash < 0) return [expanded];
232
+ const fixedScope = fixedPrefix.slice(0, lastSlash) || "/";
233
+ const canonicalScope = resolvePathForPolicy(fixedScope)?.replace(/\\/g, "/");
234
+ if (!canonicalScope || canonicalScope === fixedScope) return [expanded];
235
+ const suffix = expanded.slice(lastSlash).replace(/^\/+/, "");
236
+ const canonicalPattern = canonicalScope === "/"
237
+ ? `/${suffix}`
238
+ : `${withoutTrailingSlash(canonicalScope)}/${suffix}`;
239
+ return canonicalPattern === expanded
240
+ ? [expanded]
241
+ : [expanded, canonicalPattern];
242
+ }
243
+
244
+ function withoutTrailingSlash(path: string): string {
245
+ if (path === "/" || /^[A-Za-z]:\/$/.test(path)) return path;
246
+ return path.replace(/\/+$/, "");
247
+ }
248
+
249
+ function wildcardCanMatchDescendant(root: string, pattern: string): boolean {
250
+ const normalizedRoot = withoutTrailingSlash(
251
+ canonicalizeCase(root.replace(/\\/g, "/").normalize("NFC")),
252
+ );
253
+ const prefix = normalizedRoot === "/" || /^[A-Za-z]:\/$/.test(normalizedRoot)
254
+ ? normalizedRoot
255
+ : `${normalizedRoot}/`;
256
+ const normalizedPattern = canonicalizeCase(pattern.normalize("NFC"));
257
+ const wildcardIndex = normalizedPattern.indexOf("*");
258
+ if (wildcardIndex < 0) {
259
+ return normalizedPattern.length > prefix.length &&
260
+ normalizedPattern.startsWith(prefix);
261
+ }
262
+
263
+ const fixedPrefix = normalizedPattern.slice(0, wildcardIndex);
264
+ return prefix.startsWith(fixedPrefix) || fixedPrefix.startsWith(prefix);
265
+ }
266
+
267
+ /**
268
+ * Whether a recursive search scope can contain a path matched by `deniedPaths`.
269
+ *
270
+ * The check asks whether the wildcard pattern can match any path beginning
271
+ * with the search-root prefix. It does not scan the search tree.
272
+ */
273
+ export function recursiveSearchMayReachDeniedPath(
274
+ resolvedRoot: string,
275
+ deniedPaths: string[],
276
+ ): boolean {
277
+ if (resolvedRoot.length > MAX_WILDCARD_INPUT_LENGTH) {
278
+ return deniedPaths.length > 0;
279
+ }
93
280
  return deniedPaths.some((pattern) => {
94
- const expanded = expandHomePattern(pattern).replace(/\\/g, "/");
95
- return wildcardToRegExp(expanded).test(normalized);
281
+ if (pattern.length > MAX_WILDCARD_PATTERN_LENGTH) return true;
282
+ return deniedPatternVariants(pattern).some((expanded) =>
283
+ wildcardCanMatchDescendant(resolvedRoot, expanded)
284
+ );
96
285
  });
97
286
  }
98
287
 
@@ -102,10 +291,11 @@ export function matchesToolPattern(
102
291
  toolName: string,
103
292
  input: Record<string, unknown>,
104
293
  cwd: string,
294
+ overflowPolicy: WildcardOverflowPolicy = "match",
105
295
  ): boolean {
106
296
  if (!pattern.toolName) return false;
107
297
  if (pattern.toolName !== normalizeToolName(toolName)) return false;
108
- if (!pattern.argumentPattern || pattern.argumentPattern === "*") return true;
298
+ if (!pattern.argumentPattern) return true;
109
299
  const primary = getPrimaryArgument(toolName, input, cwd);
110
- return wildcardToRegExp(pattern.argumentPattern).test(primary);
300
+ return matchesWildcardPattern(pattern.argumentPattern, primary, overflowPolicy);
111
301
  }
@@ -37,6 +37,7 @@ export function statusText(
37
37
  `classifier denied: ${state.classifierDenied}`,
38
38
  `permissions.deny rules: ${config.permissionDeny.length}`,
39
39
  `permissions.ask rules: ${config.permissionAsk.length}`,
40
+ `permissions.allow rules: ${config.permissionAllow.length}`,
40
41
  `environment entries: ${config.environment.length}`,
41
42
  `allow entries: ${config.allow.length}`,
42
43
  `soft_deny entries: ${config.softDeny.length}`,
@@ -44,6 +44,8 @@ export type AutoModeSettings = {
44
44
  classifyReadOnlyTools?: boolean;
45
45
  /** Override the fast-stage completion token budget (default 512). */
46
46
  fastClassifierMaxTokens?: number;
47
+ /** Per-request timeout for classifier completions in milliseconds (default 20000). */
48
+ classifierTimeoutMs?: number;
47
49
  /** When true, file tools whose resolved path is inside the working directory are allowed deterministically (no classifier), and outside-CWD file access is classified. */
48
50
  allowInsideWorkingDirectory?: boolean;
49
51
  /** Path glob patterns (file tools) that are always denied before the classifier. Supports `~` and `*` (matches any characters, including `/`). */
@@ -65,6 +67,11 @@ export type SettingsFile = {
65
67
  permissions?: {
66
68
  deny?: unknown;
67
69
  ask?: unknown;
70
+ /**
71
+ * Deterministic allow tier: matching calls skip the classifier only. Read
72
+ * from user-owned config sources, never shared project config.
73
+ */
74
+ allow?: unknown;
68
75
  };
69
76
  };
70
77
 
@@ -86,6 +93,7 @@ export type EffectiveConfig = {
86
93
  classifierReasoningLevel?: ClassifierReasoningLevel;
87
94
  classifyReadOnlyTools: boolean;
88
95
  fastClassifierMaxTokens: number;
96
+ classifierTimeoutMs: number;
89
97
  allowInsideWorkingDirectory: boolean;
90
98
  deniedPaths: string[];
91
99
  maxUserTranscriptTokens: number;
@@ -97,6 +105,7 @@ export type EffectiveConfig = {
97
105
  hardDeny: string[];
98
106
  permissionDeny: ToolPattern[];
99
107
  permissionAsk: ToolPattern[];
108
+ permissionAllow: ToolPattern[];
100
109
  log: LogConfig;
101
110
  };
102
111
 
@@ -128,6 +137,7 @@ export type DenialRecord = {
128
137
  /** Denial kind plus the deterministic allow fast paths, used for decision log entries. */
129
138
  export type DecisionKind =
130
139
  | DenialRecord["kind"]
140
+ | "permissions.allow"
131
141
  | "read-only"
132
142
  | "inside-working-directory";
133
143
 
@@ -161,6 +171,7 @@ export type ClassifierIo = {
161
171
  prompt: {
162
172
  system: string;
163
173
  context: string;
174
+ action: string;
164
175
  fastInstruction: string;
165
176
  detailedInstruction: string;
166
177
  };
@@ -30,7 +30,15 @@ export function safeJson(value: unknown, maxLength = 4000): string {
30
30
  Math.max(200, Math.floor(maxLength / 4)),
31
31
  );
32
32
  }
33
- if (Array.isArray(current)) return current.slice(0, 30);
33
+ if (Array.isArray(current)) {
34
+ if (current.length <= 30) return current;
35
+ return {
36
+ $truncatedArray: true,
37
+ items: current.slice(0, 30),
38
+ omittedEntries: current.length - 30,
39
+ totalEntries: current.length,
40
+ };
41
+ }
34
42
  if (current && typeof current === "object") {
35
43
  if (seen.has(current)) return "[Circular]";
36
44
  seen.add(current);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@czottmann/pi-automode",
3
- "version": "1.11.0",
3
+ "version": "1.12.0",
4
4
  "description": "Claude Code-style auto mode guardrail for pi.",
5
5
  "repository": {
6
6
  "url": "https://github.com/czottmann/pi-automode"
@@ -25,20 +25,26 @@
25
25
  "test": "node --import tsx --test tests/*.test.ts"
26
26
  },
27
27
  "files": [
28
+ "CHANGELOG.md",
28
29
  "docs",
29
30
  "extensions",
30
31
  "examples",
32
+ "skills",
31
33
  "README.md"
32
34
  ],
33
35
  "pi": {
34
36
  "extensions": [
35
37
  "./extensions/auto-mode.ts"
38
+ ],
39
+ "skills": [
40
+ "./skills"
36
41
  ]
37
42
  },
38
43
  "peerDependencies": {
39
44
  "@earendil-works/pi-ai": "*",
40
45
  "@earendil-works/pi-coding-agent": "*",
41
- "@earendil-works/pi-tui": "*"
46
+ "@earendil-works/pi-tui": "*",
47
+ "typebox": "*"
42
48
  },
43
49
  "devDependencies": {
44
50
  "@earendil-works/pi-ai": "^0.84.1",
@@ -46,6 +52,7 @@
46
52
  "@earendil-works/pi-tui": "^0.84.1",
47
53
  "@types/node": "^24.0.0",
48
54
  "tsx": "^4.22.4",
55
+ "typebox": "^1.3.10",
49
56
  "typescript": "^5.8.0"
50
57
  }
51
58
  }
@@ -0,0 +1,63 @@
1
+ ---
2
+ name: automode-diagnostics
3
+ description: Diagnose unexpected pi-automode decisions from active state, effective rules, and observability logs. Use when auto mode blocks a safe action, allows an unsafe action, or needs a narrowly scoped rule correction.
4
+ ---
5
+
6
+ # Automode diagnostics
7
+
8
+ Use this skill to investigate a pi-automode decision. Read [Agent diagnostics](../../docs/diagnostics.md), [Classifier flow](../../docs/automode-classifier-flow.md), [Defaults](../../docs/defaults.md), and [Observability logging](../../docs/observability-logging.md) before you change configuration.
9
+
10
+ ## Safety rules
11
+
12
+ - Investigate before you edit configuration.
13
+ - Do not evade a denial with another tool, path, or disabled safety control.
14
+ - Do not weaken a rule only to make one action pass.
15
+ - Do not print credentials, tokens, private keys, signed URLs, or sensitive tool input.
16
+ - Preserve unrelated working-tree and configuration changes.
17
+ - Do not run an unsafe rejected action because auto mode is off.
18
+
19
+ `automode_inspect` is read-only. It cannot change auto mode or configuration. Its output is model-visible and omits denial reasons and action payloads. Treat truncated output as incomplete.
20
+
21
+ ## Investigation
22
+
23
+ 1. Call `automode_inspect` with `status`, `config`, and `denials`.
24
+ 2. Read the reported log path only when observability logging is enabled.
25
+ 3. Use the denial timestamp, enforcement kind, and tool name to locate the relevant decision entry.
26
+ 4. Inspect a decision reason only after you verify that it cannot contain sensitive input.
27
+ 5. Identify the enforcement layer before you propose a change:
28
+ - `permissions.deny` and `permissions.ask` are local rules.
29
+ - `deterministic-hard-deny` is extension code.
30
+ - `read-only` is a local allow.
31
+ - `classifier` is a model decision.
32
+ 6. Do not tune classifier rules to solve a permission or deterministic denial.
33
+
34
+ For a classifier decision, use the effective configuration and the logged classifier metadata when available. Do not dump full prompts or raw responses. They can contain session context and tool input.
35
+
36
+ ## Configuration correction
37
+
38
+ Only change configuration when the user explicitly requests a correction.
39
+
40
+ 1. Explain the proposed narrow change.
41
+ 2. Ask the user to run `/automode off`.
42
+ 3. Wait for confirmation.
43
+ 4. While auto mode is off, edit only the requested automode configuration and related evidence files.
44
+ 5. Validate JSON syntax and the exact diff.
45
+ 6. Ask the user to run `/automode reload` and then `/automode on`.
46
+ 7. Call `automode_inspect` with `status` and `config` to confirm that auto mode is enabled and the new configuration is active.
47
+ 8. Retry an action only when the user requested it and the action is safe.
48
+
49
+ When a correction replaces a rule list, check whether it retains `$defaults`. Keep unrelated protections. Test the corrected non-secret case, the secret-bearing variant, an unapproved destination, and an unrelated hard-deny case.
50
+
51
+ ## Report
52
+
53
+ Report:
54
+
55
+ - the active configuration and log paths;
56
+ - the decision timestamp, ID, and enforcement layer;
57
+ - the evidence for the decision;
58
+ - the proposed or applied narrow change;
59
+ - validation performed and expected regression cases;
60
+ - remaining uncertainty; and
61
+ - whether auto mode is enabled.
62
+
63
+ If the session ends while auto mode is off, tell the user to run `/automode on`.