javi-forge 1.32.0 → 1.33.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.
@@ -4,11 +4,14 @@
4
4
  * three lib fns to human output + exit codes; it adds NO new security logic and
5
5
  * never touches `runTransaction`/secure-fs directly.
6
6
  *
7
- * Honest-execution constraint (spec Requirement "…never fabricates execution
8
- * status"): the doctor renderer reports effective execution as `inconclusive`
9
- * only. It MUST NOT print, imply, or default to `RUNNABLE` real host-probing
10
- * is Slice 4b. Exit 2 (INCONCLUSIVE gate semantics) is likewise reserved for 4b
11
- * and never emitted here.
7
+ * Honest-execution gate (Slice 4b): the doctor renderer prints the real
8
+ * `execution` verdict computed by the library `runnable`, `blocked`, or
9
+ * `inconclusive` plus its `blockers`, `unknownSources`, and the constant
10
+ * `residual` caveats, in committed stable order. The doctor exit code follows
11
+ * `execution.status` (runnable 0, blocked → 1, inconclusive → 2), independent
12
+ * of `report.healthy`. The renderer never fabricates `runnable`: the library
13
+ * only reports it when every relevant local source read clear and the guard is
14
+ * current.
12
15
  */
13
16
  import { doctorClaudePreToolUse, installClaudePreToolUse, repairClaudePreToolUse } from "../lib/claude-hook-manager.js";
14
17
  export type ClaudeHookSub = "install" | "doctor" | "repair";
@@ -24,7 +27,8 @@ export interface ClaudeHookCmdDeps {
24
27
  * Run one Claude-hook subcommand against `projectDir`. Returns the process exit
25
28
  * code (the dispatcher calls `process.exit`, not this fn):
26
29
  * - install/repair: 0 when `ok`, 1 on refusal/failure.
27
- * - doctor: always 0 (informational).
30
+ * - doctor: follows `execution.status` — runnable → 0, blocked → 1,
31
+ * inconclusive → 2.
28
32
  */
29
33
  export declare function runClaudeHookCommand(sub: ClaudeHookSub, projectDir: string, opts: {
30
34
  force?: boolean;
@@ -4,11 +4,14 @@
4
4
  * three lib fns to human output + exit codes; it adds NO new security logic and
5
5
  * never touches `runTransaction`/secure-fs directly.
6
6
  *
7
- * Honest-execution constraint (spec Requirement "…never fabricates execution
8
- * status"): the doctor renderer reports effective execution as `inconclusive`
9
- * only. It MUST NOT print, imply, or default to `RUNNABLE` real host-probing
10
- * is Slice 4b. Exit 2 (INCONCLUSIVE gate semantics) is likewise reserved for 4b
11
- * and never emitted here.
7
+ * Honest-execution gate (Slice 4b): the doctor renderer prints the real
8
+ * `execution` verdict computed by the library `runnable`, `blocked`, or
9
+ * `inconclusive` plus its `blockers`, `unknownSources`, and the constant
10
+ * `residual` caveats, in committed stable order. The doctor exit code follows
11
+ * `execution.status` (runnable 0, blocked → 1, inconclusive → 2), independent
12
+ * of `report.healthy`. The renderer never fabricates `runnable`: the library
13
+ * only reports it when every relevant local source read clear and the guard is
14
+ * current.
12
15
  */
13
16
  import { doctorClaudePreToolUse, installClaudePreToolUse, repairClaudePreToolUse, } from "../lib/claude-hook-manager.js";
14
17
  function renderMutation(verb, result, log, logError) {
@@ -39,23 +42,43 @@ function renderDoctor(report, log) {
39
42
  log(` settings: ${report.settings.state} — ${report.settings.detail}`);
40
43
  log(` asset: ${report.asset.state} — ${report.asset.detail}`);
41
44
  log(` node: ${report.node.version ?? "unavailable"} (min-satisfied: ${report.node.satisfiesMinimum})`);
42
- // Honest stub: 4a cannot confirm the guard is live. Never RUNNABLE.
43
- log(" execution: inconclusive (effective-execution probe deferred to 4b)");
45
+ const execution = report.execution;
46
+ log(` execution: ${execution.status}`);
47
+ if (execution.blockers.length > 0) {
48
+ log(" blockers:");
49
+ for (const b of execution.blockers)
50
+ log(` - ${b}`);
51
+ }
52
+ if (execution.unknownSources.length > 0) {
53
+ log(" unknown-sources:");
54
+ for (const u of execution.unknownSources)
55
+ log(` - ${u}`);
56
+ }
57
+ if (execution.residual.length > 0) {
58
+ log(" execution-residual:");
59
+ for (const r of execution.residual)
60
+ log(` - ${r}`);
61
+ }
44
62
  log(` host-residual: ${report.hostResidual}`);
45
63
  if (report.remediation.length > 0) {
46
64
  log(" remediation:");
47
65
  for (const r of report.remediation)
48
66
  log(` - ${r}`);
49
67
  }
50
- // Doctor is informational, not a gate always exit 0 (spec Scenario
51
- // "Doctor reports component health").
68
+ // The doctor exit code follows the effective-execution verdict, independent
69
+ // of component health: runnable → 0, blocked → 1, inconclusive → 2.
70
+ if (execution.status === "blocked")
71
+ return 1;
72
+ if (execution.status === "inconclusive")
73
+ return 2;
52
74
  return 0;
53
75
  }
54
76
  /**
55
77
  * Run one Claude-hook subcommand against `projectDir`. Returns the process exit
56
78
  * code (the dispatcher calls `process.exit`, not this fn):
57
79
  * - install/repair: 0 when `ok`, 1 on refusal/failure.
58
- * - doctor: always 0 (informational).
80
+ * - doctor: follows `execution.status` — runnable → 0, blocked → 1,
81
+ * inconclusive → 2.
59
82
  */
60
83
  export async function runClaudeHookCommand(sub, projectDir, opts, deps = {}) {
61
84
  const log = deps.log ?? ((m) => console.log(m));
@@ -51,6 +51,7 @@ export interface ClaudeHookDoctorReport {
51
51
  coverage: typeof COVERAGE;
52
52
  hostResidual: string;
53
53
  remediation: readonly string[];
54
+ execution: ExecutionReport;
54
55
  }
55
56
  /**
56
57
  * Classify the asset into one of nine states from observed bytes only. Never
@@ -66,14 +67,100 @@ export declare function detectNode(nodeVersion: string | undefined): {
66
67
  version?: string;
67
68
  satisfiesMinimum: boolean;
68
69
  };
70
+ /**
71
+ * The honest effective-execution verdict. `status` is derived by precedence
72
+ * (blocked > inconclusive > runnable); `runnable` is reached only when every
73
+ * relevant local source read clear AND the managed guard is current. `residual`
74
+ * carries CONSTANT honest caveats (server-delivered policy, session safe-mode)
75
+ * that are always shown but never gate the status — otherwise `runnable` would
76
+ * be unreachable.
77
+ */
78
+ export interface ExecutionReport {
79
+ status: "runnable" | "blocked" | "inconclusive";
80
+ blockers: string[];
81
+ unknownSources: string[];
82
+ residual: string[];
83
+ }
84
+ /** Injectable seams so units never hard-read real `/etc` or `/Library`. */
85
+ export interface ExecutionProbeEnv {
86
+ platform?: NodeJS.Platform;
87
+ homeDir?: string;
88
+ env?: NodeJS.ProcessEnv;
89
+ /** Override the managed OS file (null → no managed file at all). */
90
+ managedFile?: string | null;
91
+ /** Override the managed drop-in dir (null → no drop-ins). */
92
+ managedDropInDir?: string | null;
93
+ /** Override the drop-in directory listing (defaults to a confined readdir). */
94
+ listDir?: (dir: string) => Promise<string[]>;
95
+ }
96
+ /** Per-source read outcome; never promotes an unobservable source to clear. */
97
+ export type ExecutionSourceProbe = {
98
+ kind: "clear";
99
+ } | {
100
+ kind: "blocking";
101
+ flag: "disableAllHooks" | "allowManagedHooksOnly";
102
+ } | {
103
+ kind: "unknown";
104
+ reason: string;
105
+ };
106
+ /** The already-computed component states the guard-currency check consumes. */
107
+ export interface ExecutionComponentStates {
108
+ asset: ClaudeHookComponentState;
109
+ settings: ClaudeHookComponentState;
110
+ }
111
+ export interface ManagedSettingsPaths {
112
+ file: string;
113
+ dropInDir: string;
114
+ }
115
+ /** Static managed-settings locations per OS (no fs). WSL reports `linux`. */
116
+ export declare function resolveManagedSettingsPaths(platform: NodeJS.Platform): ManagedSettingsPaths;
117
+ /**
118
+ * Probe one settings source path for the two documented hook-neutralizing
119
+ * flags. Fail-closed: only a genuinely-absent path or a cleanly-parsed source
120
+ * with no flag is `clear`; a symlink, non-regular path, permission/io error,
121
+ * oversized/binary content, or malformed JSON is `unknown` (unreadable ≠
122
+ * absent), never `clear`. `disableAllHooks` is preferred over
123
+ * `allowManagedHooksOnly` when both are set (the former blocks at any source).
124
+ */
125
+ export declare function probeExecutionSource(target: string): Promise<ExecutionSourceProbe>;
126
+ /**
127
+ * Confined readdir result of the managed drop-in dir, fail-closed and mirroring
128
+ * `probeExecutionSource`'s errno classification: a genuinely-absent directory
129
+ * (ENOENT/ENOTDIR) is the ONLY empty/clear case, so it yields `{ entries: [] }`
130
+ * (no drop-ins). ANY other readdir failure (EACCES/EIO/ELOOP/…) means the
131
+ * directory is present-but-unenumerable and MUST NOT be treated as empty — it
132
+ * yields `{ unreadable: true }` so the caller can degrade the verdict, never
133
+ * silently report "no drop-ins" (a false `runnable`).
134
+ */
135
+ export type ManagedDropInListing = {
136
+ unreadable?: false;
137
+ entries: string[];
138
+ } | {
139
+ unreadable: true;
140
+ reason: string;
141
+ };
142
+ export declare function listManagedDropIns(dir: string, listDir?: (dir: string) => Promise<string[]>): Promise<ManagedDropInListing>;
143
+ /**
144
+ * The fail-closed effective-execution verdict. Gathers all sources in stable
145
+ * order (project, local, user, managed OS file, drop-ins sorted), classifies
146
+ * each with `probeExecutionSource`, applies the managed-only inertness of
147
+ * `allowManagedHooksOnly` (blocks only from a managed source — hooks MERGE
148
+ * elsewhere), folds in the guard-currency blocker and the doctor-process
149
+ * safe-mode observation, then resolves by precedence
150
+ * (`blockers` first, then `unknownSources`, else `runnable`). An `unknown` is
151
+ * NEVER promoted to `runnable`.
152
+ */
153
+ export declare function probeExecution(projectDir: string, componentStates: ExecutionComponentStates, env?: ExecutionProbeEnv): Promise<ExecutionReport>;
69
154
  /**
70
155
  * Assemble the read-only component-level doctor report (no writes). `healthy` is
71
156
  * exactly: both components `managed-current`, matcher and command shape exact,
72
157
  * Node `>=22`. `assetSettingsConsistent` is a reported advisory, NOT part of it.
158
+ * The `execution` verdict is INDEPENDENT of `healthy`.
73
159
  */
74
160
  export declare function doctorClaudePreToolUse(projectDir: string, options?: {
75
161
  manifest?: Manifest;
76
162
  nodeVersion?: string;
163
+ execution?: ExecutionProbeEnv;
77
164
  }): Promise<ClaudeHookDoctorReport>;
78
165
  export interface ClaudeHookMutationResult {
79
166
  ok: boolean;
@@ -8,11 +8,12 @@
8
8
  * Slice-3 seams — Slice 3 GROWS this file, it does not relocate this code.
9
9
  */
10
10
  import { createHash, randomBytes } from "node:crypto";
11
- import { lstat, readFile } from "node:fs/promises";
11
+ import { lstat, readdir, readFile } from "node:fs/promises";
12
+ import os from "node:os";
12
13
  import path from "node:path";
13
14
  import { CLAUDE_HOOK_ASSETS_DIR } from "../constants.js";
14
15
  import { ASSET_MANAGED_MARKER, ASSET_NAME, } from "./__fixtures__/claude-hook-ownership.js";
15
- import { buildManagedContainer, classifySettingsEntry, isPlainObject, LEGACY_FILE_SHA256, MANAGED_ASSET_ARG, MANAGED_MATCHER, MANAGED_STATUS_PREFIX, planForceReplace, planLegacyCohortExcision, planManagedClaudeHookMerge, } from "./claude-hook-settings.js";
16
+ import { buildManagedContainer, classifySettingsEntry, isPlainObject, LEGACY_FILE_SHA256, MANAGED_ASSET_ARG, MANAGED_MATCHER, MANAGED_STATUS_PREFIX, planForceReplace, planLegacyCohortExcision, planManagedClaudeHookMerge, scanExecutionFlags, } from "./claude-hook-settings.js";
16
17
  import { safeReadFile } from "./safe-read.js";
17
18
  import { selectSecureFs } from "./secure-fs-posix.js";
18
19
  import { runTransaction, } from "./secure-fs-transaction.js";
@@ -200,10 +201,201 @@ const REMEDIATION = {
200
201
  function remediationFor(state, component) {
201
202
  return REMEDIATION[state]?.replace("$", component);
202
203
  }
204
+ /** Static managed-settings locations per OS (no fs). WSL reports `linux`. */
205
+ export function resolveManagedSettingsPaths(platform) {
206
+ if (platform === "darwin") {
207
+ const base = "/Library/Application Support/ClaudeCode";
208
+ return {
209
+ file: `${base}/managed-settings.json`,
210
+ dropInDir: `${base}/managed-settings.d`,
211
+ };
212
+ }
213
+ if (platform === "win32") {
214
+ const base = "C:\\Program Files\\ClaudeCode";
215
+ return {
216
+ file: `${base}\\managed-settings.json`,
217
+ dropInDir: `${base}\\managed-settings.d`,
218
+ };
219
+ }
220
+ const base = "/etc/claude-code";
221
+ return {
222
+ file: `${base}/managed-settings.json`,
223
+ dropInDir: `${base}/managed-settings.d`,
224
+ };
225
+ }
226
+ /**
227
+ * Probe one settings source path for the two documented hook-neutralizing
228
+ * flags. Fail-closed: only a genuinely-absent path or a cleanly-parsed source
229
+ * with no flag is `clear`; a symlink, non-regular path, permission/io error,
230
+ * oversized/binary content, or malformed JSON is `unknown` (unreadable ≠
231
+ * absent), never `clear`. `disableAllHooks` is preferred over
232
+ * `allowManagedHooksOnly` when both are set (the former blocks at any source).
233
+ */
234
+ export async function probeExecutionSource(target) {
235
+ const stat = await lstatNoFollow(target);
236
+ if (stat.kind === "enoent")
237
+ return { kind: "clear" };
238
+ if (stat.kind === "symlink")
239
+ return { kind: "unknown", reason: "symlink" };
240
+ if (stat.kind === "non-regular") {
241
+ return { kind: "unknown", reason: "non-regular" };
242
+ }
243
+ if (stat.kind === "error")
244
+ return { kind: "unknown", reason: stat.detail };
245
+ const read = await safeReadFile(target, READ_OPTS);
246
+ if (!read.ok) {
247
+ if (read.reason === "not-found")
248
+ return { kind: "clear" };
249
+ return { kind: "unknown", reason: read.reason };
250
+ }
251
+ let parsed;
252
+ try {
253
+ parsed = JSON.parse(read.content);
254
+ }
255
+ catch {
256
+ return { kind: "unknown", reason: "invalid-json" };
257
+ }
258
+ const flags = scanExecutionFlags(parsed);
259
+ if (flags.disableAllHooks) {
260
+ return { kind: "blocking", flag: "disableAllHooks" };
261
+ }
262
+ if (flags.allowManagedHooksOnly) {
263
+ return { kind: "blocking", flag: "allowManagedHooksOnly" };
264
+ }
265
+ return { kind: "clear" };
266
+ }
267
+ export async function listManagedDropIns(dir, listDir) {
268
+ let entries;
269
+ try {
270
+ entries = listDir ? await listDir(dir) : await readdir(dir);
271
+ }
272
+ catch (error) {
273
+ const code = error.code;
274
+ // Only a genuinely-absent directory is "no drop-ins"; every other error
275
+ // (permission/io/loop/etc.) is an unreadable managed source.
276
+ if (code === "ENOENT" || code === "ENOTDIR")
277
+ return { entries: [] };
278
+ return { unreadable: true, reason: code ?? String(error) };
279
+ }
280
+ return {
281
+ entries: entries
282
+ .filter((name) => name.endsWith(".json"))
283
+ .sort()
284
+ .map((name) => path.join(dir, name)),
285
+ };
286
+ }
287
+ /** CONSTANT honest limits — always rendered, never gate the status. */
288
+ const EXECUTION_RESIDUAL = [
289
+ "server-delivered managed policy can disable hooks and is not observable from local files",
290
+ "session safe-mode (--safe-mode / CLAUDE_CODE_SAFE_MODE) in the diagnosed session is not observable from this process",
291
+ ];
292
+ function isSafeModeTruthy(env) {
293
+ const value = env.CLAUDE_CODE_SAFE_MODE;
294
+ if (value === undefined)
295
+ return false;
296
+ const normalized = value.trim().toLowerCase();
297
+ return normalized !== "" && normalized !== "0" && normalized !== "false";
298
+ }
299
+ /**
300
+ * The fail-closed effective-execution verdict. Gathers all sources in stable
301
+ * order (project, local, user, managed OS file, drop-ins sorted), classifies
302
+ * each with `probeExecutionSource`, applies the managed-only inertness of
303
+ * `allowManagedHooksOnly` (blocks only from a managed source — hooks MERGE
304
+ * elsewhere), folds in the guard-currency blocker and the doctor-process
305
+ * safe-mode observation, then resolves by precedence
306
+ * (`blockers` first, then `unknownSources`, else `runnable`). An `unknown` is
307
+ * NEVER promoted to `runnable`.
308
+ */
309
+ export async function probeExecution(projectDir, componentStates, env = {}) {
310
+ const platform = env.platform ?? process.platform;
311
+ const homeDir = env.homeDir ?? os.homedir();
312
+ const processEnv = env.env ?? process.env;
313
+ const resolved = resolveManagedSettingsPaths(platform);
314
+ const managedFile = env.managedFile !== undefined ? env.managedFile : resolved.file;
315
+ const dropInDir = env.managedDropInDir !== undefined
316
+ ? env.managedDropInDir
317
+ : resolved.dropInDir;
318
+ const specs = [
319
+ {
320
+ label: "project",
321
+ target: path.join(projectDir, ".claude", "settings.json"),
322
+ managed: false,
323
+ },
324
+ {
325
+ label: "local",
326
+ target: path.join(projectDir, ".claude", "settings.local.json"),
327
+ managed: false,
328
+ },
329
+ {
330
+ label: "user",
331
+ target: path.join(homeDir, ".claude", "settings.json"),
332
+ managed: false,
333
+ },
334
+ ];
335
+ if (managedFile) {
336
+ specs.push({ label: "managed", target: managedFile, managed: true });
337
+ }
338
+ // A present-but-unenumerable drop-in dir is an `unknown` managed source, NOT
339
+ // "no drop-ins" — fold it into unknownSources so a blocking drop-in policy we
340
+ // cannot read can never be mistaken for a clear (false `runnable`) verdict.
341
+ let dropInDirUnknown;
342
+ if (dropInDir) {
343
+ const listing = await listManagedDropIns(dropInDir, env.listDir);
344
+ if (listing.unreadable) {
345
+ dropInDirUnknown = `managed:${dropInDir} (${listing.reason})`;
346
+ }
347
+ else {
348
+ for (const target of listing.entries) {
349
+ specs.push({ label: "managed", target, managed: true });
350
+ }
351
+ }
352
+ }
353
+ const blockers = [];
354
+ const unknownSources = [];
355
+ for (const spec of specs) {
356
+ const probe = await probeExecutionSource(spec.target);
357
+ if (probe.kind === "clear")
358
+ continue;
359
+ if (probe.kind === "unknown") {
360
+ unknownSources.push(`${spec.label}:${spec.target} (${probe.reason})`);
361
+ continue;
362
+ }
363
+ // `allowManagedHooksOnly` is inert outside a managed source (hooks merge).
364
+ if (probe.flag === "allowManagedHooksOnly" && !spec.managed)
365
+ continue;
366
+ blockers.push(`policy:${probe.flag}@${spec.label}`);
367
+ }
368
+ if (dropInDirUnknown)
369
+ unknownSources.push(dropInDirUnknown);
370
+ // Guard-currency: a not-installed / drifted guard cannot fire, so it blocks.
371
+ if (componentStates.asset !== "managed-current") {
372
+ blockers.push(`guard:asset=${componentStates.asset}`);
373
+ }
374
+ if (componentStates.settings !== "managed-current") {
375
+ blockers.push(`guard:settings=${componentStates.settings}`);
376
+ }
377
+ // Safe-mode observed in THIS doctor process is a real per-run unknown (the
378
+ // diagnosed session's own safe-mode remains a constant residual).
379
+ if (isSafeModeTruthy(processEnv)) {
380
+ unknownSources.push("safe-mode:CLAUDE_CODE_SAFE_MODE (observed in doctor process only)");
381
+ }
382
+ const status = blockers.length > 0
383
+ ? "blocked"
384
+ : unknownSources.length > 0
385
+ ? "inconclusive"
386
+ : "runnable";
387
+ return {
388
+ status,
389
+ blockers,
390
+ unknownSources,
391
+ residual: [...EXECUTION_RESIDUAL],
392
+ };
393
+ }
203
394
  /**
204
395
  * Assemble the read-only component-level doctor report (no writes). `healthy` is
205
396
  * exactly: both components `managed-current`, matcher and command shape exact,
206
397
  * Node `>=22`. `assetSettingsConsistent` is a reported advisory, NOT part of it.
398
+ * The `execution` verdict is INDEPENDENT of `healthy`.
207
399
  */
208
400
  export async function doctorClaudePreToolUse(projectDir, options) {
209
401
  const manifest = options?.manifest ?? (await readManifest());
@@ -240,6 +432,7 @@ export async function doctorClaudePreToolUse(projectDir, options) {
240
432
  signals.matcherExact &&
241
433
  signals.commandShapeExact &&
242
434
  node.satisfiesMinimum;
435
+ const execution = await probeExecution(projectDir, { asset: asset.state, settings: settings.state }, options?.execution ?? {});
243
436
  return {
244
437
  healthy,
245
438
  settings: {
@@ -261,6 +454,7 @@ export async function doctorClaudePreToolUse(projectDir, options) {
261
454
  coverage: COVERAGE,
262
455
  hostResidual: HOST_RESIDUAL,
263
456
  remediation: [...remediation].sort(),
457
+ execution,
264
458
  };
265
459
  }
266
460
  async function readManifest() {
@@ -40,6 +40,24 @@ export declare function isPlainObject(value: unknown): value is Record<string, u
40
40
  * else is malformed.
41
41
  */
42
42
  export declare function validateSettingsShape(parsed: unknown): boolean;
43
+ /**
44
+ * The two documented scalar flags a settings source can set that neutralize the
45
+ * managed hook. `disableAllHooks: true` at ANY source is a blocker; the
46
+ * source→blocker mapping for `allowManagedHooksOnly` (managed-only) lives in the
47
+ * manager, which knows each source's provenance.
48
+ */
49
+ export interface ExecutionFlagScan {
50
+ disableAllHooks: boolean;
51
+ allowManagedHooksOnly: boolean;
52
+ }
53
+ /**
54
+ * Classify an already-parsed settings container for the two documented
55
+ * hook-neutralizing flags. Strict `=== true` only: a truthy-but-not-`true`
56
+ * value (`"true"`, `1`), a missing key, or a non-object input is NOT a flag.
57
+ * A false here is a definitive "this source does not set the flag", never an
58
+ * "unknown" — unreadability is decided upstream by the fs probe, not here.
59
+ */
60
+ export declare function scanExecutionFlags(parsed: unknown): ExecutionFlagScan;
43
61
  /**
44
62
  * Replace the trailing 64-hex asset SHA token in a managed `statusMessage` with
45
63
  * the fixed placeholder so settings identity is invariant under asset rotation.
@@ -31,6 +31,22 @@ export function validateSettingsShape(parsed) {
31
31
  return false;
32
32
  return true;
33
33
  }
34
+ /**
35
+ * Classify an already-parsed settings container for the two documented
36
+ * hook-neutralizing flags. Strict `=== true` only: a truthy-but-not-`true`
37
+ * value (`"true"`, `1`), a missing key, or a non-object input is NOT a flag.
38
+ * A false here is a definitive "this source does not set the flag", never an
39
+ * "unknown" — unreadability is decided upstream by the fs probe, not here.
40
+ */
41
+ export function scanExecutionFlags(parsed) {
42
+ if (!isPlainObject(parsed)) {
43
+ return { disableAllHooks: false, allowManagedHooksOnly: false };
44
+ }
45
+ return {
46
+ disableAllHooks: parsed.disableAllHooks === true,
47
+ allowManagedHooksOnly: parsed.allowManagedHooksOnly === true,
48
+ };
49
+ }
34
50
  // Canonical identity (Decision ②)
35
51
  const ASSET_SHA_TOKEN = /^[0-9a-f]{64}$/;
36
52
  const VERSION_PATTERN = /^javi-forge-global-pretooluse:v(\d+):sha256:/;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javi-forge",
3
- "version": "1.32.0",
3
+ "version": "1.33.0",
4
4
  "description": "Project scaffolding and AI-ready CI bootstrap",
5
5
  "type": "module",
6
6
  "bin": {