@dieulc/workflow 0.11.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -181,7 +181,9 @@ export function classifyReadCall(
181
181
  const result: ReadCallClassification = { tool, targets: [], uncertain: [] };
182
182
 
183
183
  if (tool === "bash" || tool === "powershell") {
184
- const analysis = analyzeShellPaths(obj.command, cwd);
184
+ const analysis = analyzeShellPaths(obj.command, cwd, {
185
+ family: tool === "powershell" ? "powershell" : "posix",
186
+ });
185
187
  result.targets = analysis.reads;
186
188
  result.uncertain = analysis.uncertain;
187
189
  return result;
package/read-policy.ts CHANGED
@@ -37,6 +37,7 @@ import type {
37
37
  FolderOutsideAction,
38
38
  } from "./folder-policy.ts";
39
39
  import {
40
+ dialogGrantRootForTarget,
40
41
  grantRootForTarget,
41
42
  outsideProjectRootForTarget,
42
43
  } from "./folder-policy.ts";
@@ -327,6 +328,8 @@ export type ReadGateDecision = "allow" | "ask" | "block";
327
328
  export interface ReadGateInput {
328
329
  /** A target matches the sensitive deny list. */
329
330
  sensitive: boolean;
331
+ /** A target IS an excluded folder (never askable). */
332
+ excluded?: boolean;
330
333
  /** A target is outside the folder roots. */
331
334
  outside: boolean;
332
335
  outsideAction: FolderOutsideAction;
@@ -339,6 +342,7 @@ export interface ReadGateInput {
339
342
 
340
343
  /**
341
344
  * sensitive → block (never softened by a grant)
345
+ * excluded → block (never askable)
342
346
  * outside + block → block
343
347
  * outside + ask → ask unless already granted
344
348
  * uncertain + strict → ask
@@ -346,6 +350,7 @@ export interface ReadGateInput {
346
350
  */
347
351
  export function decideReadGate(input: ReadGateInput): ReadGateDecision {
348
352
  if (input.sensitive) return "block";
353
+ if (input.excluded) return "block";
349
354
  if (input.outside) {
350
355
  if (input.outsideAction === "block") return "block";
351
356
  if (!input.granted) return "ask";
@@ -390,7 +395,10 @@ export type ReadGateChoice =
390
395
  | "deny";
391
396
 
392
397
  /** Labels for the ask dialog; the parser and the UI share this table. */
393
- export function readGateOptions(kind: ReadGateItemKind): readonly string[] {
398
+ export function readGateOptions(
399
+ kind: ReadGateItemKind,
400
+ opts: { allowAlways?: boolean } = {},
401
+ ): readonly string[] {
394
402
  const always =
395
403
  kind === "folder"
396
404
  ? "Always allow this folder"
@@ -401,7 +409,7 @@ export function readGateOptions(kind: ReadGateItemKind): readonly string[] {
401
409
  : "";
402
410
  return [
403
411
  "Allow once (this turn)",
404
- ...(always ? [always] : []),
412
+ ...(opts.allowAlways !== false && always ? [always] : []),
405
413
  "Deny",
406
414
  READ_DENY_WITH_INSTRUCTIONS_LABEL,
407
415
  ];
@@ -435,7 +443,7 @@ export function sanitizeReadInstruction(raw: unknown): string {
435
443
  }
436
444
 
437
445
  export interface ReadDenyReasonInput {
438
- kind: "sensitive" | "outside" | "uncertain" | "blocked";
446
+ kind: "sensitive" | "excluded" | "outside" | "uncertain" | "blocked";
439
447
  target: string;
440
448
  pattern?: string;
441
449
  outsideAction?: FolderOutsideAction;
@@ -456,6 +464,13 @@ export function buildReadDenyReason(input: ReadDenyReasonInput): string {
456
464
  ". This cannot be overridden by a folder grant. Do not retry; ask the user if they want to add a manual `sensitiveExemptions` entry." +
457
465
  guidance
458
466
  );
467
+ case "excluded":
468
+ return (
469
+ `Read policy: \`${input.target}\` is excluded by \`workflow.folderPolicy.excludePaths\`` +
470
+ (input.pattern ? ` (${input.pattern})` : "") +
471
+ ". The excluded folder itself is off-limits and cannot be approved from a dialog. Do not retry; a specific sub-folder can still be requested (it needs the user's approval), or ask the user to revoke the rule with `/folder-policy exclude revoke`." +
472
+ guidance
473
+ );
459
474
  case "blocked":
460
475
  return (
461
476
  `Read policy: \`${input.target}\` is outside the permitted folders and the folder policy is 'block'. Do not retry. Ask the user to open the project at that folder or change /folder-policy.` +
@@ -497,15 +512,14 @@ export function readGateItemFromTarget(
497
512
  tool: string,
498
513
  target: FolderTarget,
499
514
  why: string,
515
+ cwd: string,
500
516
  baseRoot?: string,
501
517
  ): ReadGateItem {
502
- const grantRoot =
503
- (baseRoot && outsideProjectRootForTarget(target, baseRoot)) ||
504
- grantRootForTarget(target);
518
+ const { root } = dialogGrantRootForTarget(target, baseRoot, cwd);
505
519
  return {
506
520
  tool,
507
521
  target,
508
- grantRoot,
522
+ grantRoot: root,
509
523
  why,
510
524
  key: readGateTargetKey(target),
511
525
  };
@@ -565,12 +579,16 @@ export function readGateDetail(
565
579
  export function readGateTitle(
566
580
  items: readonly ReadGateItem[],
567
581
  base?: string,
582
+ opts: { grantBlockedReason?: string } = {},
568
583
  ): string {
569
584
  const detail = readGateDetail(items);
570
585
  return (
571
586
  `Read policy: allow ${describeReadGateItems(items)}?` +
572
587
  (base ? `\nOutside: ${base}` : "") +
573
- (detail ? `\n${detail}` : "")
588
+ (detail ? `\n${detail}` : "") +
589
+ (opts.grantBlockedReason
590
+ ? `\nGrant not available: ${opts.grantBlockedReason}`
591
+ : "")
574
592
  );
575
593
  }
576
594
 
package/review-runtime.ts CHANGED
@@ -75,6 +75,15 @@ const SETTLE_IN_FLIGHT_FALLBACK_MS = 60_000;
75
75
  export interface ReviewRuntimeDeps {
76
76
  cwd: string;
77
77
  agentDir: string;
78
+ /**
79
+ * The parent's effective folder policy (sensitive extras, `excludePaths`)
80
+ * and read grants, handed to the reviewer's guarded tools. Optional so
81
+ * existing tests and embedders keep working.
82
+ */
83
+ folderPolicy?: () => {
84
+ config: import("./folder-policy.ts").FolderPolicyConfig;
85
+ grants: readonly string[];
86
+ };
78
87
  config: () => ReviewModeConfig | undefined;
79
88
  /** Resolve the reviewer role's model (falls back to the planner when unset). */
80
89
  reviewerModel: () =>
@@ -822,6 +831,7 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
822
831
  const built = createReviewTools(sdk, {
823
832
  cwd: deps.cwd,
824
833
  log: deps.log,
834
+ folderPolicy: deps.folderPolicy,
825
835
  explore: {
826
836
  // Step 8: Planner recon rides along in the prompt — when it is
827
837
  // present the reviewer verifies instead of re-exploring.
package/review-tools.ts CHANGED
@@ -58,6 +58,11 @@ export interface ReviewBashDeps {
58
58
  cwd: string;
59
59
  /** Surface refusals and anomalies to the parent session. */
60
60
  log: (message: string, level?: "info" | "warning" | "error") => void;
61
+ /** The parent's folder policy + read grants (see ReviewToolDeps). */
62
+ folderPolicy?: () => {
63
+ config: FolderPolicyConfig;
64
+ grants: readonly string[];
65
+ };
61
66
  }
62
67
 
63
68
  /** Injectable shell-tool builder so this module is testable without the SDK. */
@@ -155,18 +160,24 @@ export function createReviewBashTool(
155
160
  };
156
161
  }
157
162
 
158
- const guard = reviewerReadGuard(deps.cwd);
163
+ const guard = reviewerReadGuard(deps.cwd, deps.folderPolicy?.());
159
164
  const readPaths = analyzeShellPaths(command, deps.cwd).reads;
160
165
  const readEval = evaluateFolderTargets(readPaths, {
161
166
  config: guard.config,
162
- grants: [],
167
+ grants: guard.grants,
163
168
  cwd: deps.cwd,
164
169
  lockedRoots: [guard.root],
170
+ explicitGrants: guard.grants,
165
171
  action: "read",
166
172
  });
167
- if (readEval.sensitive.length > 0 || readEval.outside.length > 0) {
173
+ if (
174
+ readEval.sensitive.length > 0 ||
175
+ readEval.excluded.length > 0 ||
176
+ readEval.outside.length > 0
177
+ ) {
168
178
  const target =
169
179
  readEval.sensitive[0]?.path ??
180
+ readEval.excluded[0]?.path ??
170
181
  readEval.outside[0]?.path ??
171
182
  "the requested path";
172
183
  deps.log(
@@ -192,7 +203,14 @@ export function createReviewBashTool(
192
203
  onUpdate,
193
204
  ctx,
194
205
  )) as ReviewToolTextResult;
195
- return redactReviewResult(result, "bash", rawParams, deps.cwd, guard.config);
206
+ return redactReviewResult(
207
+ result,
208
+ "bash",
209
+ rawParams,
210
+ deps.cwd,
211
+ guard.config,
212
+ { base: [guard.root] },
213
+ );
196
214
  } catch (e: unknown) {
197
215
  return {
198
216
  content: [
@@ -380,6 +398,15 @@ export interface ReviewToolDeps {
380
398
  explore: ReviewExploreDeps;
381
399
  submit: ReviewSubmitDeps;
382
400
  passDone: ReviewPassDoneDeps;
401
+ /**
402
+ * The parent's effective folder policy (sensitive extras, `excludePaths`)
403
+ * and read grants. The reviewer is a separate session with no ambient
404
+ * extensions, so without this it would ignore the user's deny layer.
405
+ */
406
+ folderPolicy?: () => {
407
+ config: FolderPolicyConfig;
408
+ grants: readonly string[];
409
+ };
383
410
  }
384
411
 
385
412
  // ── Steps 9-11: review_submit_plan ───────────────────────────────────
@@ -499,6 +526,10 @@ export function createReviewPlanBeginTool(deps: ReviewSubmitDeps): {
499
526
  ),
500
527
  }),
501
528
  async execute(...args: unknown[]): Promise<ReviewToolTextResult> {
529
+ // SAFETY: Pi calls every tool `execute(toolCallId, params, …)`,
530
+ // so index 1 is the model-supplied params object. This tool's
531
+ // schema declares `totalChunks`, which is narrowed below before
532
+ // use — nothing else is read off the value.
502
533
  const [, rawParams] = args as unknown as [
503
534
  string,
504
535
  { totalChunks?: unknown },
@@ -574,6 +605,9 @@ export function createReviewPlanChunkTool(deps: ReviewSubmitDeps): {
574
605
  }),
575
606
  }),
576
607
  async execute(...args: unknown[]): Promise<ReviewToolTextResult> {
608
+ // SAFETY: Pi calls every tool `execute(toolCallId, params, …)`,
609
+ // so index 1 is the model-supplied params object. `index` and
610
+ // `content` are narrowed below before use.
577
611
  const [, rawParams] = args as unknown as [
578
612
  string,
579
613
  { index?: unknown; content?: unknown },
@@ -1057,16 +1091,22 @@ export function createReviewPassDoneTool(deps: ReviewPassDoneDeps): {
1057
1091
  interface ReviewerReadGuard {
1058
1092
  root: string;
1059
1093
  config: FolderPolicyConfig;
1094
+ /** Explicit grants that can re-open an excluded subtree. */
1095
+ grants: readonly string[];
1060
1096
  }
1061
1097
 
1062
- function reviewerReadGuard(cwd: string): ReviewerReadGuard {
1098
+ function reviewerReadGuard(
1099
+ cwd: string,
1100
+ parent?: { config: FolderPolicyConfig; grants: readonly string[] },
1101
+ ): ReviewerReadGuard {
1063
1102
  return {
1064
1103
  root: detectRepoRoot(cwd) ?? cwd,
1065
1104
  config: {
1066
- ...DEFAULT_FOLDER_POLICY_CONFIG,
1105
+ ...(parent?.config ?? DEFAULT_FOLDER_POLICY_CONFIG),
1067
1106
  mode: "cwd",
1068
1107
  outside: "block",
1069
1108
  },
1109
+ grants: parent?.grants ?? [],
1070
1110
  };
1071
1111
  }
1072
1112
 
@@ -1108,13 +1148,17 @@ function guardReviewerTarget(
1108
1148
  const hit = matchSensitivePath(value, guard.config, cwd);
1109
1149
  const evaluation = evaluateFolderTargets([target], {
1110
1150
  config: guard.config,
1111
- grants: [],
1151
+ grants: guard.grants,
1112
1152
  cwd,
1113
1153
  lockedRoots: [guard.root],
1154
+ explicitGrants: guard.grants,
1114
1155
  action: "read",
1115
1156
  });
1116
1157
  if (hit.sensitive || evaluation.sensitive.length > 0)
1117
1158
  return reviewerRefusal(target);
1159
+ // The excluded folder itself, or a path inside it that no approved grant
1160
+ // re-opens, is refused to the reviewer too.
1161
+ if (evaluation.excluded.length > 0) return reviewerRefusal(target);
1118
1162
  if (evaluation.outside.length > 0) return reviewerRefusal(target);
1119
1163
  return undefined;
1120
1164
  }
@@ -1125,11 +1169,19 @@ function redactReviewResult(
1125
1169
  input: Record<string, unknown>,
1126
1170
  cwd: string,
1127
1171
  config: FolderPolicyConfig,
1172
+ roots?: { base?: readonly string[]; grants?: readonly string[] },
1128
1173
  ): ReviewToolTextResult {
1129
1174
  let redacted = false;
1130
1175
  const content = (result.content ?? []).map((block) => {
1131
1176
  if (block?.type !== "text") return block;
1132
- const next = redactSensitiveText(block.text, tool, input, cwd, config);
1177
+ const next = redactSensitiveText(
1178
+ block.text,
1179
+ tool,
1180
+ input,
1181
+ cwd,
1182
+ config,
1183
+ roots,
1184
+ );
1133
1185
  if (next.redacted) redacted = true;
1134
1186
  return next.redacted ? { ...block, text: next.text } : block;
1135
1187
  });
@@ -1140,10 +1192,11 @@ function redactReviewResult(
1140
1192
  function createGuardedBuiltinTools(
1141
1193
  sdk: PiCodingAgentSdk | undefined,
1142
1194
  cwd: string,
1195
+ parent?: { config: FolderPolicyConfig; grants: readonly string[] },
1143
1196
  ): { tools: unknown[]; unavailable: string[] } {
1144
1197
  const tools: unknown[] = [];
1145
1198
  const unavailable: string[] = [];
1146
- const guard = reviewerReadGuard(cwd);
1199
+ const guard = reviewerReadGuard(cwd, parent);
1147
1200
  const builders: Array<[string, string, unknown]> = [
1148
1201
  ["read", "read", (sdk as any)?.createReadToolDefinition],
1149
1202
  ["grep", "grep", (sdk as any)?.createGrepToolDefinition],
@@ -1169,7 +1222,9 @@ function createGuardedBuiltinTools(
1169
1222
  const result = (await definition.execute(
1170
1223
  ...args,
1171
1224
  )) as ReviewToolTextResult;
1172
- return redactReviewResult(result, tool, input, cwd, guard.config);
1225
+ return redactReviewResult(result, tool, input, cwd, guard.config, {
1226
+ base: [guard.root],
1227
+ });
1173
1228
  },
1174
1229
  });
1175
1230
  }
@@ -1196,7 +1251,11 @@ export function createReviewTools(
1196
1251
  // The reviewer session has NO ambient extensions, so its built-in
1197
1252
  // read/grep/find/ls are otherwise a complete bypass of the read gate.
1198
1253
  // Custom tools with those names override the built-ins.
1199
- const guarded = createGuardedBuiltinTools(sdk, deps.cwd);
1254
+ const guarded = createGuardedBuiltinTools(
1255
+ sdk,
1256
+ deps.cwd,
1257
+ deps.folderPolicy?.(),
1258
+ );
1200
1259
  tools.push(...guarded.tools);
1201
1260
  unavailable.push(...guarded.unavailable);
1202
1261
 
package/shell-paths.ts CHANGED
@@ -25,6 +25,14 @@ import {
25
25
  type FolderTarget,
26
26
  normalizePolicyPath,
27
27
  } from "./folder-policy.ts";
28
+ import { translateMsysTempPath } from "./utils.ts";
29
+
30
+ export type ShellPathFamily = "posix" | "powershell" | "cmd";
31
+
32
+ export interface ShellPathOptions {
33
+ /** MSYS `/tmp` translation is a POSIX-shell concern (default: "posix"). */
34
+ family?: ShellPathFamily;
35
+ }
28
36
 
29
37
  export type GitCommitHit = "add" | "commit" | "push";
30
38
 
@@ -225,6 +233,7 @@ function dedupeTargets(targets: FolderTarget[]): FolderTarget[] {
225
233
  export function analyzeShellPaths(
226
234
  command: unknown,
227
235
  cwd: string,
236
+ options: ShellPathOptions = {},
228
237
  ): ShellPathAnalysis {
229
238
  const analysis: ShellPathAnalysis = {
230
239
  reads: [],
@@ -234,13 +243,18 @@ export function analyzeShellPaths(
234
243
  if (typeof command !== "string" || !command.trim()) return analysis;
235
244
  const parsed = analyzeCommand(command);
236
245
  let virtualCwd = normalizePolicyPath(cwd, cwd);
246
+ const family = options.family ?? "posix";
247
+ const translate = (value: string): string =>
248
+ family === "posix" ? translateMsysTempPath(value, { cwd }) : value;
249
+ const normalizeShellPath = (value: string, base = virtualCwd): string =>
250
+ normalizePolicyPath(translate(value), base);
237
251
 
238
252
  // Best-effort command substitution: `echo $(cat ../x)` / backticks. Only
239
253
  // simple, non-nested bodies are parsed; the rest stays uncertain.
240
254
  for (const match of command.matchAll(/\$\(([^()]*)\)|`([^`]*)`/g)) {
241
255
  const inner = (match[1] ?? match[2] ?? "").trim();
242
256
  if (!inner) continue;
243
- const nested = analyzeShellPaths(inner, virtualCwd);
257
+ const nested = analyzeShellPaths(inner, virtualCwd, { family });
244
258
  for (const target of nested.reads) analysis.reads.push(target);
245
259
  for (const target of nested.writes) analysis.writes.push(target);
246
260
  for (const reason of nested.uncertain) analysis.uncertain.push(reason);
@@ -248,7 +262,7 @@ export function analyzeShellPaths(
248
262
  }
249
263
 
250
264
  const addRead = (value: string, kind: FolderTarget["kind"] = "file") => {
251
- const normalized = normalizePolicyPath(value, virtualCwd);
265
+ const normalized = normalizeShellPath(value);
252
266
  if (!normalized || normalized === "-") return;
253
267
  if (/^\d+$/.test(normalized)) return;
254
268
  if (normalized.endsWith("/dev/null") || normalized.endsWith("/dev/stdin"))
@@ -258,7 +272,7 @@ export function analyzeShellPaths(
258
272
  analysis.reads.push({ path: normalized, kind });
259
273
  };
260
274
  const addWrite = (value: string, kind: FolderTarget["kind"] = "file") => {
261
- const normalized = normalizePolicyPath(value, virtualCwd);
275
+ const normalized = normalizeShellPath(value);
262
276
  if (!normalized || normalized === "-") return;
263
277
  if (normalized.endsWith("/dev/null")) return;
264
278
  analysis.writes.push({ path: normalized, kind });
@@ -269,6 +283,8 @@ export function analyzeShellPaths(
269
283
  analysis,
270
284
  addRead,
271
285
  addWrite,
286
+ family,
287
+ normalizePath: (value: string) => normalizeShellPath(value),
272
288
  get cwd() {
273
289
  return virtualCwd;
274
290
  },
@@ -286,6 +302,9 @@ export function analyzeShellPaths(
286
302
  interface SegmentContext {
287
303
  analysis: ShellPathAnalysis;
288
304
  cwd: string;
305
+ family: ShellPathFamily;
306
+ /** Translate + normalize one shell path against the current cwd. */
307
+ normalizePath: (value: string) => string;
289
308
  addRead: (value: string, kind?: FolderTarget["kind"]) => void;
290
309
  addWrite: (value: string, kind?: FolderTarget["kind"]) => void;
291
310
  }
@@ -317,7 +336,7 @@ function analyzeSegment(segment: SegmentAnalysis, ctx: SegmentContext): void {
317
336
  ctx.analysis.uncertain.push(`cd ${target}`);
318
337
  return;
319
338
  }
320
- const next = normalizePolicyPath(target, ctx.cwd);
339
+ const next = ctx.normalizePath(target);
321
340
  if (next) ctx.cwd = next;
322
341
  return;
323
342
  }
@@ -759,7 +778,7 @@ function analyzeShell(
759
778
  ctx.analysis.uncertain.push(`${command} ${word} without a command`);
760
779
  return;
761
780
  }
762
- mergeAnalysis(ctx, analyzeShellPaths(inner, ctx.cwd));
781
+ mergeAnalysis(ctx, analyzeShellPaths(inner, ctx.cwd, { family: ctx.family }));
763
782
  return;
764
783
  }
765
784
  if (isFlag(word)) continue;
@@ -827,7 +846,7 @@ function analyzeGit(words: string[], ctx: SegmentContext): void {
827
846
  const subcommand = (command[index] ?? "").toLowerCase();
828
847
  const args = command.slice(index + 1);
829
848
  const repoRoot = rootFlag
830
- ? normalizePolicyPath(rootFlag, ctx.cwd)
849
+ ? ctx.normalizePath(rootFlag)
831
850
  : normalizePolicyPath(ctx.cwd, ctx.cwd);
832
851
  if (rootFlag) {
833
852
  // Git metadata and worktree are both read; the commit policy uses repoRoot.
@@ -884,7 +903,9 @@ function analyzeGit(words: string[], ctx: SegmentContext): void {
884
903
  ctx.analysis.git = {
885
904
  hit: subcommand,
886
905
  repoRoot,
887
- explicitPaths: explicitPaths.map((p) => normalizePolicyPath(p, repoRoot)),
906
+ explicitPaths: explicitPaths.map((p) =>
907
+ normalizePolicyPath(ctx.normalizePath(p), repoRoot),
908
+ ),
888
909
  allPaths,
889
910
  amend,
890
911
  usesCwd,
@@ -953,7 +974,7 @@ function addGitRead(
953
974
  value: string,
954
975
  repoRoot: string,
955
976
  ): void {
956
- const normalized = normalizePolicyPath(value, repoRoot);
977
+ const normalized = normalizePolicyPath(ctx.normalizePath(value), repoRoot);
957
978
  if (normalized) ctx.analysis.reads.push({ path: normalized, kind: "file" });
958
979
  }
959
980
 
package/utils.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import * as fs from "node:fs";
8
+ import * as path from "node:path";
8
9
  import { fileURLToPath } from "node:url";
9
10
 
10
11
  import { isCommandAllowedForRole } from "./permissions.ts";
@@ -74,6 +75,103 @@ export function translateForeignPath(value: unknown): {
74
75
  return { path: raw };
75
76
  }
76
77
 
78
+ /** Options for the Git-Bash `/tmp` translator (injectable for tests). */
79
+ export interface MsysPathOptions {
80
+ env?: NodeJS.ProcessEnv;
81
+ platform?: NodeJS.Platform;
82
+ exists?: (value: string) => boolean;
83
+ /** Only translate when the session cwd is a native Windows path. */
84
+ cwd?: string;
85
+ }
86
+
87
+ /** A native Windows absolute path (drive-absolute or UNC). */
88
+ export function isNativeWindowsPath(value: string): boolean {
89
+ return /^[A-Za-z]:[\\/]/.test(value) || /^\\\\/.test(value);
90
+ }
91
+
92
+ let msysTempCache: { key: string; root: string | undefined } | undefined;
93
+
94
+ function msysTempCacheKey(env: NodeJS.ProcessEnv): string {
95
+ return [
96
+ env.MSYSTEM ?? "",
97
+ env.SHELL ?? "",
98
+ env.ProgramFiles ?? "",
99
+ env.PI_MSYS_TMP ?? "",
100
+ env.TMP ?? "",
101
+ env.TEMP ?? "",
102
+ env.TMPDIR ?? "",
103
+ env.LOCALAPPDATA ?? "",
104
+ env.USERPROFILE ?? "",
105
+ ].join("\0");
106
+ }
107
+
108
+ /**
109
+ * The real Windows directory behind Git-Bash's `/tmp`.
110
+ *
111
+ * Only meaningful for MSYS/Git-Bash (Pi's default Windows shell): Git for
112
+ * Windows is detected by `%ProgramFiles%\Git\bin\bash.exe`, `MSYSTEM`, or a
113
+ * bash `SHELL`. Candidates are tried in order and the first existing one
114
+ * wins. Injected `env`/`exists` options bypass the cache (tests); no
115
+ * `cygpath` subprocess.
116
+ */
117
+ export function resolveMsysTempRoot(
118
+ opts: MsysPathOptions = {},
119
+ ): string | undefined {
120
+ const platform = opts.platform ?? process.platform;
121
+ if (platform !== "win32") return undefined;
122
+ const env = opts.env ?? process.env;
123
+ const exists = opts.exists ?? fs.existsSync;
124
+ const injected =
125
+ opts.env !== undefined ||
126
+ opts.exists !== undefined ||
127
+ opts.platform !== undefined;
128
+ const key = msysTempCacheKey(env);
129
+ if (!injected && msysTempCache?.key === key) return msysTempCache.root;
130
+ const enabled =
131
+ Boolean(env.PI_MSYS_TMP) ||
132
+ Boolean(env.MSYSTEM) ||
133
+ /bash(?:\.exe)?$/i.test(env.SHELL ?? "") ||
134
+ Boolean(
135
+ env.ProgramFiles &&
136
+ exists(path.join(env.ProgramFiles, "Git", "bin", "bash.exe")),
137
+ );
138
+ if (!enabled) {
139
+ if (!injected) msysTempCache = { key, root: undefined };
140
+ return undefined;
141
+ }
142
+ const candidates: string[] = [];
143
+ if (env.PI_MSYS_TMP && isNativeWindowsPath(env.PI_MSYS_TMP))
144
+ candidates.push(env.PI_MSYS_TMP);
145
+ for (const value of [env.TMP, env.TEMP, env.TMPDIR]) {
146
+ if (value && isNativeWindowsPath(value)) candidates.push(value);
147
+ }
148
+ if (env.LOCALAPPDATA)
149
+ candidates.push(path.join(env.LOCALAPPDATA, "Temp"));
150
+ if (env.USERPROFILE)
151
+ candidates.push(path.join(env.USERPROFILE, "AppData", "Local", "Temp"));
152
+ const root = candidates.find((candidate) => exists(candidate));
153
+ if (!injected) msysTempCache = { key, root };
154
+ return root;
155
+ }
156
+
157
+ /**
158
+ * Translate the literal `/tmp` prefix (and only that) to the real Windows
159
+ * temp directory for a POSIX shell. Direct tool paths keep Node semantics
160
+ * (`/tmp` really is `C:/tmp` there), so callers opt in.
161
+ */
162
+ export function translateMsysTempPath(
163
+ value: string,
164
+ opts: MsysPathOptions = {},
165
+ ): string {
166
+ if (!value) return value;
167
+ const platform = opts.platform ?? process.platform;
168
+ if (platform !== "win32") return value;
169
+ if (!/^\/tmp(?=\/|$)/.test(value)) return value;
170
+ if (opts.cwd && !isNativeWindowsPath(opts.cwd)) return value;
171
+ const root = resolveMsysTempRoot(opts);
172
+ return root ? `${root}${value.slice(4)}` : value;
173
+ }
174
+
77
175
  /**
78
176
  * Backward-compatible alias for Plan Mode's command policy (read-only plus
79
177
  * test/lint/typecheck). New code should call