pi-orchestrator-config 4.7.3 → 4.8.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/README.md CHANGED
@@ -48,12 +48,17 @@ Single extension that provides:
48
48
  | -------------- | --------------------------------------------------------------------------------------------------------------- |
49
49
  | Languages | python-expert, go-expert, ts-expert, java-expert, bash-expert |
50
50
  | Infrastructure | docker-expert, kubernetes-expert, jenkins-expert |
51
- | Dev workflow | git-expert, github-expert, test-runner, test-automator, debugger |
51
+ | Dev workflow | git-expert, github-expert, conflict-resolver, test-runner, test-automator, debugger |
52
52
  | Documentation | technical-documentation-writer, api-documenter, docs-fetcher |
53
53
  | Code review | code-reviewer-quality, code-reviewer-guidelines, code-reviewer-security, code-reviewer-docs, code-reviewer-spec |
54
54
  | Security | security-auditor |
55
55
  | Workflow | scout, planner, worker, reviewer |
56
56
 
57
+ `git-expert` cannot resolve a conflict — it is blocked from staging a side of one by
58
+ enforcement, not just by prompt. When a merge, rebase or cherry-pick stops with unresolved
59
+ files, delegate to `conflict-resolver`, which resolves them and hands the staged result back
60
+ for `git-expert` to commit.
61
+
57
62
  ### Prompt Templates
58
63
 
59
64
  | Prompt | Description |
@@ -0,0 +1,37 @@
1
+ ---
2
+ name: conflict-resolver
3
+ description: Resolve git merge, rebase, and cherry-pick conflicts by reading commit intent on both sides, where git-expert is blocked from resolving them and hands off instead.
4
+ tools: read, bash, edit, write
5
+ ---
6
+
7
+ You resolve merge/rebase/cherry-pick conflicts. `git-expert` is blocked from doing this and
8
+ hands conflicts to you; `enforcement.ts` blocks `git add`, `git restore`, `git checkout
9
+ --ours/--theirs`, and `merge|rebase --continue` there while conflicts are unresolved.
10
+
11
+ Read the skill before touching a file, and resolve its path at runtime — an agent runs in the
12
+ target project, which usually has no `skills/` directory:
13
+
14
+ ```bash
15
+ ls -1 ~/.pi/agent/npm/node_modules/pi-orchestrator-config/skills/conflict-resolver/SKILL.md \
16
+ 2>/dev/null || ls -1 skills/conflict-resolver/SKILL.md 2>/dev/null
17
+ ```
18
+
19
+ If neither path exists, work from the non-negotiables below; they are sufficient on their own.
20
+
21
+ - **Understand both sides first** — `git ls-files --unmerged`, then the commits each side brought.
22
+ Never resolve a file you have not read the history of.
23
+ - **`--ours`/`--theirs` are swapped during rebase.** Ours is upstream. If unsure, compare stages:
24
+ `git diff :2:<file> :3:<file>`.
25
+ - **Never pick a side for a lock file.** Resolve the manifest, delete the lock, regenerate
26
+ (`uv lock`, `npm install`).
27
+ - **Never commit.** Only `git-expert` may, and the harness rejects it here. Your job ends with the
28
+ working tree resolved and staged: `git add <resolved files>`, then report. Which sequencer the
29
+ caller continues depends on what was in progress — `git merge --continue`, `git rebase --continue`
30
+ or `git cherry-pick --continue` — so say which one you found in your report rather than assuming
31
+ rebase. The commit itself is also `git-expert`'s.
32
+ - **Never** force-push, reset away either side, or skip pre-commit hooks.
33
+ - Prefer merging both sides when the intents are compatible; a bug fix or security fix wins over
34
+ a feature; style-only conflicts take either side.
35
+
36
+ Finish by staging resolved files, running the project's tests for what you touched, and reporting
37
+ per file: which side won, why, and anything you could not confidently resolve.
@@ -88,6 +88,28 @@ Before ANY `git commit`, if `review_loop_enforcement` is `true`:
88
88
  - NEVER use `--no-verify` flag
89
89
  - Branch prefixes: `feature/`, `fix/`, `hotfix/`, `refactor/`
90
90
 
91
+ ## Merge Conflicts
92
+
93
+ Do NOT resolve merge, rebase, or cherry-pick conflicts. After any of those commands, check:
94
+
95
+ ```bash
96
+ git ls-files --unmerged
97
+ ```
98
+
99
+ If it lists any file, STOP and report:
100
+
101
+ ```text
102
+ ⛔ Unresolved conflicts in <files>. Delegate to the conflict-resolver agent.
103
+ ```
104
+
105
+ Enforcement blocks `git add`, `git restore`, `git rm`, `git update-index`, `git reset`,
106
+ `git checkout --ours/--theirs`, `git merge --continue`, `git rebase --continue`, and
107
+ `git cherry-pick --continue` while conflicts are unresolved — you will not be able to resolve
108
+ here even if you try. A command that starts a merge, rebase, cherry-pick or stash and then
109
+ stages a file is blocked as well: the conflict does not exist yet, so the check cannot see it.
110
+ Back out with `git merge --abort`, `git rebase --abort`, or `git cherry-pick --abort` and let the
111
+ caller call `conflict-resolver`.
112
+
91
113
  ## Separation of Concerns
92
114
 
93
115
  - This agent does NOT run tests or fix code.
@@ -128,4 +150,4 @@ Format rules:
128
150
  ## Scope
129
151
 
130
152
  **Handles:** commit, branch, merge, rebase, stash, cherry-pick, log, diff, status, config
131
- **Delegate:** PRs, issues, releases, workflows → `github-expert`
153
+ **Delegate:** merge/rebase/cherry-pick conflicts → `conflict-resolver`; PRs, issues, releases, workflows → `github-expert`
@@ -7,7 +7,7 @@ import { createLogger } from "../shared/logger.js";
7
7
  import { realpathSync } from "node:fs";
8
8
  import * as path from "node:path";
9
9
  import { join } from "node:path";
10
- import { DANGEROUS, getCurrentBranch, hasGitSub } from "./git-helpers.js";
10
+ import { DANGEROUS, executableText, getCurrentBranch, hasGitSub, isConflictResolutionCommand, segmentRunsGit, tokenize } from "./git-helpers.js";
11
11
 
12
12
  const enfLog = createLogger("enforcement");
13
13
  enfLog.debug("enforcement-helpers module loaded");
@@ -59,26 +59,540 @@ export function commandHasTrailerByName(command: string, trailerName: string): b
59
59
  return result;
60
60
  }
61
61
 
62
+ /**
63
+ * Split a command into segments on shell separators that are *outside* quotes,
64
+ * recording each segment's offset, its parenthesis depth, and whether the
65
+ * separator before it was `||` (so the segment may never run). Quoted text is
66
+ * data, not syntax: a `(` or `cd` inside a string is part of an argument, never
67
+ * a boundary.
68
+ *
69
+ * Depth and conditionality are what let the caller tell a `cd` that moves this
70
+ * shell from one inside a subshell or behind a short-circuit, which change
71
+ * nothing for a later command.
72
+ */
73
+ interface ShellSegment {
74
+ text: string;
75
+ start: number;
76
+ depth: number;
77
+ conditional: boolean;
78
+ /** `cmd &` runs the command in a subshell, so its cd does not move this shell. */
79
+ backgrounded?: boolean;
80
+ }
81
+
82
+ function unquotedSegments(command: string): ShellSegment[] {
83
+ const segments: ShellSegment[] = [];
84
+ let start = 0;
85
+ let quote: string | null = null;
86
+ let depth = 0;
87
+ let conditional = false;
88
+ const push = (end: number, startAt: number, cond: boolean, backgrounded = false) => {
89
+ segments.push({
90
+ text: command.slice(startAt, end),
91
+ start: startAt,
92
+ depth,
93
+ conditional: cond,
94
+ ...(backgrounded ? { backgrounded: true } : {}),
95
+ });
96
+ };
97
+ // The index of the first segment in the current `&&`/`||` chain. A trailing
98
+ // `&` backgrounds the whole chain, not just the command beside it.
99
+ let chainStart = 0;
100
+ const chainLen = () => segments.length;
101
+ for (let i = 0; i < command.length; i++) {
102
+ const ch = command[i];
103
+ if (quote) {
104
+ if (ch === quote) quote = null;
105
+ // A quote spans a newline: `cd "conflicted\nrepo"` is one multi-line path.
106
+ // An unterminated quote is not recovered from here either — bash reports
107
+ // one and runs nothing, and the caller fails closed when no directory
108
+ // can be resolved.
109
+ continue;
110
+ }
111
+ if (ch === "\\" && command[i + 1]) {
112
+ // An escaped character is part of a word: `weird\&name` is one directory
113
+ // name, not a name followed by a background operator.
114
+ i++;
115
+ continue;
116
+ }
117
+ if (ch === "'" || ch === '"') {
118
+ quote = ch;
119
+ continue;
120
+ }
121
+ // A separator is a single "&&", "||", ";", "|", or newline; "(" and ")"
122
+ // bracket a subshell but are not separators on their own.
123
+ const two = command.slice(i, i + 2);
124
+ if (two === "&&" || two === "||") {
125
+ push(i, start, conditional);
126
+ // What FOLLOWS a short-circuit may be skipped: `false && cd x` never runs
127
+ // it, and `cd a || cd b` runs the second only if the first failed.
128
+ conditional = true;
129
+ i++;
130
+ start = i + 1;
131
+ } else if (ch === "\n") {
132
+ // An unterminated quote cannot survive a line break in practice; drop the
133
+ // state so one stray apostrophe cannot blind the directory parser for the
134
+ // rest of the command.
135
+ if (quote) {
136
+ enfLog.debug("quote_reset_at_newline", "char", quote);
137
+ quote = null;
138
+ }
139
+ push(i, start, conditional);
140
+ conditional = false;
141
+ start = i + 1;
142
+ chainStart = chainLen();
143
+ } else if (ch === ";") {
144
+ push(i, start, conditional);
145
+ conditional = false;
146
+ start = i + 1;
147
+ chainStart = chainLen();
148
+ } else if (ch === "|") {
149
+ // Every pipeline component runs in a subshell of its own, so a directory
150
+ // change on the left of the pipe does not reach the right. Marking it
151
+ // backgrounded is exactly the same rule as a trailing ampersand.
152
+ push(i, start, conditional, true);
153
+ conditional = false;
154
+ start = i + 1;
155
+ chainStart = chainLen();
156
+ } else if (ch === "&") {
157
+ // `2>&1` and `>&2` redirect; a lone `&` backgrounds the whole chain it
158
+ // ends, so every segment of that chain runs in a subshell.
159
+ const redirect = command[i - 1] === ">";
160
+ if (redirect) continue;
161
+ const isDouble = command[i + 1] === "&";
162
+ push(i, start, conditional, !isDouble);
163
+ if (!isDouble) {
164
+ for (let s = chainStart; s < segments.length; s++) segments[s].backgrounded = true;
165
+ }
166
+ conditional = false;
167
+ start = i + 1;
168
+ chainStart = chainLen();
169
+ } else if (ch === "(") {
170
+ depth++;
171
+ } else if (ch === ")") {
172
+ if (depth > 0) {
173
+ // The closing paren ends the segment it belongs to; only what follows
174
+ // it is back at the outer depth.
175
+ push(i, start, false);
176
+ depth--;
177
+ start = i + 1;
178
+ conditional = false;
179
+ }
180
+ }
181
+ }
182
+ push(command.length, start, conditional);
183
+ enfLog.debug("shell_segments", "count", segments.length);
184
+ return segments;
185
+ }
186
+
187
+ /** The target of a `cd` at the start of a segment, honouring quoting and `~`. */
188
+ function cdTargetIn(segment: string): string | null {
189
+ // A cd may follow a shell keyword or a builtin wrapper: `if cd /repo; then …`,
190
+ // `command cd /repo`.
191
+ const m = /^\s*(?:(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)|if\s+|then\s+|else\s+|elif\s+|do\s+|\(\s*|\{\s*)*(?:(?:command|builtin)\s+)*(cd|pushd|popd)\b\s*([\s\S]*)$/.exec(
192
+ segment,
193
+ );
194
+ if (!m) {
195
+ enfLog.debug("cd_target_absent", "segment_head", segment.trim().slice(0, 24));
196
+ return null;
197
+ }
198
+ // `popd` returns to whatever `pushd` displaced, and takes no argument.
199
+ if (m[1] === "popd") return "~popd";
200
+ const args = m[2];
201
+ // Walk the arguments: skip options, then read one destination — a quoted
202
+ // target whole, so a directory name may contain spaces or parentheses.
203
+ let i = 0;
204
+ const skipSpace = () => { while (i < args.length && /\s/.test(args[i])) i++; };
205
+ for (;;) {
206
+ skipSpace();
207
+ if (i >= args.length) break;
208
+ if (args.startsWith("--", i)) { i += 2; continue; }
209
+ // A lone `-` is the previous directory, not an option.
210
+ if (args[i] === "-" && (i + 1 >= args.length || /\s/.test(args[i + 1]))) {
211
+ enfLog.debug("cd_target_previous");
212
+ return "-";
213
+ }
214
+ if (args[i] === "-" && i + 1 < args.length) {
215
+ while (i < args.length && !/\s/.test(args[i])) i++;
216
+ continue;
217
+ }
218
+ if (args[i] === "\\" && i + 1 < args.length) {
219
+ i += 2;
220
+ continue;
221
+ }
222
+ const quote = args[i];
223
+ if (quote === "'" || quote === '"') {
224
+ const end = args.indexOf(quote, i + 1);
225
+ let target = end === -1 ? args.slice(i + 1) : args.slice(i + 1, end);
226
+ i = end === -1 ? args.length : end + 1;
227
+ // The shell concatenates adjacent parts into one word: `cd "work tree"x`
228
+ // is `work treex`, so keep whatever follows the closing quote.
229
+ while (i < args.length && !/[\s;&|)]/.test(args[i])) {
230
+ target += args[i];
231
+ i++;
232
+ }
233
+ // Inside quotes a backslash is literal, so it is doubled here and the
234
+ // caller unescapes it back: `cd 'foo\ bar'` is one directory whose name
235
+ // contains a backslash, not a space.
236
+ const literal = target.replace(/\\/g, "\\\\");
237
+ enfLog.debug("cd_target_quoted", "target", literal);
238
+ return literal;
239
+ }
240
+ let end = i;
241
+ // An escaped character is part of the name: `weird\&name` is one directory.
242
+ while (end < args.length && !/[\s;&|)]/.test(args[end])) {
243
+ if (args[end] === "\\" && end + 1 < args.length) end++;
244
+ end++;
245
+ }
246
+ // Unquoted, a backslash before a newline is a continuation the shell removes
247
+ // entirely. Inside quotes it would be literal, which is why only this path
248
+ // joins and the quoted one above does not.
249
+ const target = args.slice(i, end).replace(/\\\n/g, "");
250
+ enfLog.debug("cd_target", "target", target);
251
+ return target || null;
252
+ }
253
+ // Bare `cd` goes home; `cd -` goes to the previous directory, which the
254
+ // caller resolves against what it tracked.
255
+ enfLog.debug("cd_target_default");
256
+ return "~";
257
+ }
258
+
259
+ /** Apply one directory change to a running directory. */
260
+ function applyCd(dir: string, target: string, home = process.env.HOME ?? "~", fromShell = true): string {
261
+ // `cd -` with no previous directory tracked leaves the shell where it is. Only
262
+ // a shell's own `cd -` means that: a directory literally named `-`, reached as
263
+ // `git -C-`, is an ordinary relative directory.
264
+ if (target === "-" && fromShell) {
265
+ enfLog.debug("apply_cd_previous_untracked", "dir", dir);
266
+ return dir;
267
+ }
268
+ let next: string;
269
+ // The shell builds one word from backslash escapes: `weird\&name` is a single
270
+ // directory named `weird&name`, whatever else the name looks like.
271
+ const word = target.replace(/\\(.)/g, "$1");
272
+ if (word.startsWith("~/")) next = join(home, word.slice(2));
273
+ else if (word === "~") next = home;
274
+ else if (word.startsWith("/")) next = word;
275
+ else next = join(dir, word);
276
+ enfLog.debug("apply_cd", "from", dir, "target", target, "to", next);
277
+ return next;
278
+ }
279
+
280
+ /**
281
+ * Every `-C` directory override in a git segment, in order.
282
+ *
283
+ * Only git's *global* options count. `-C` after the subcommand is a different
284
+ * flag entirely — `git commit -C <sha>` reuses that commit's message — so
285
+ * reading it as a directory would send every git safety guard looking for a
286
+ * directory named after a commit, and skip the checks. The walk therefore stops
287
+ * at the first bare word, which is the subcommand.
288
+ */
289
+ function gitCsIn(segment: string): string[] {
290
+ const tokens = tokenize(segment);
291
+ const start = tokens.findIndex((w) => w === "git");
292
+ if (start === -1) return [];
293
+ const values: string[] = [];
294
+ for (let i = start + 1; i < tokens.length; i++) {
295
+ const tok = tokens[i];
296
+ if (!tok.startsWith("-") || tok === "-") break;
297
+ if (tok === "-C" || tok.startsWith("-C") && tok.length > 2) {
298
+ const value = tok === "-C" ? tokens[i + 1] : tok.slice(2);
299
+ if (value) values.push(value);
300
+ if (tok === "-C") i++;
301
+ continue;
302
+ }
303
+ // Any other option may take a separate value; skip it if the next word is one.
304
+ if (!tok.includes("=") && tokens[i + 1] && !tokens[i + 1].startsWith("-")) i++;
305
+ }
306
+ if (values.length) enfLog.debug("git_C_options", "count", values.length, "last", values[values.length - 1]);
307
+ return values;
308
+ }
309
+
310
+ /** Words that run whatever follows them, so an assignment behind one still counts. */
311
+ const WRAPPER_WORDS = new Set(["sudo", "command", "exec", "time", "nohup", "nice", "stdbuf", "setsid", "timeout"]);
312
+
313
+ /** env options whose value is a separate word. */
314
+ const ENV_VALUE_OPTIONS = new Set([
315
+ "-u", "--unset", "-C", "--chdir", "-S", "--split-string", "-a", "--argv0",
316
+ "--block-signal", "--default-signal", "--ignore-signal", "--debug",
317
+ ]);
318
+
319
+ const GIT_INDEX_VARS = new Set(["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"]);
320
+ const GIT_INDEX_OPTIONS = new Set(["--git-dir", "--work-tree", "--namespace"]);
321
+
322
+ /**
323
+ * Anything that points git at a repository other than the working directory: the
324
+ * GIT_DIR/GIT_WORK_TREE/GIT_INDEX_FILE environment variables, or the equivalent
325
+ * long options. Their target is an index path, not a directory the walker can
326
+ * enumerate, so the caller refuses rather than checks the wrong places.
327
+ *
328
+ * Position matters. An environment variable only selects the index in *assignment
329
+ * position* — `GIT_DIR=/x git add a.txt`, optionally through `env` — so
330
+ * `git add GIT_INDEX_FILE=notes` stages a file with that name. The long options
331
+ * only count in git's global option region, before the subcommand, so
332
+ * `git add -- --work-tree=x` is a filename too.
333
+ */
334
+ function gitIndexOverride(command: string): string | null {
335
+ const assignment = /^[A-Za-z_][A-Za-z0-9_]*=/;
336
+ for (const seg of unquotedSegments(command)) {
337
+ const tokens = tokenize(seg.text);
338
+ const nameOf = (tok: string) => tok.split("=")[0];
339
+ let i = 0;
340
+ // `export GIT_INDEX_FILE=/x` sets it for later commands in the same shell,
341
+ // so it points git at another index just as an inline assignment does. This
342
+ // segment does not run git itself, so it is checked before that requirement.
343
+ if (tokens[i] === "export") {
344
+ i++;
345
+ while (i < tokens.length) {
346
+ const name = nameOf(tokens[i]);
347
+ if (GIT_INDEX_VARS.has(name)) {
348
+ enfLog.warn("git_index_override", "source", name, "via", "export");
349
+ return name;
350
+ }
351
+ i++;
352
+ }
353
+ continue;
354
+ }
355
+ // `echo GIT_INDEX_FILE=/x` only prints the name. An override is something
356
+ // git is actually run with, so the segment has to run git for it to count.
357
+ if (!segmentRunsGit(seg.text)) continue;
358
+ // Leading `VAR=value` assignments, then the same again after `env`. A
359
+ // wrapper that runs the rest — `sudo`, `command`, `exec`, `time` — sits in
360
+ // front of both, and can be chained.
361
+ for (let round = 0; round < 4; round++) {
362
+ while (i < tokens.length && WRAPPER_WORDS.has(tokens[i])) i++;
363
+ while (i < tokens.length && assignment.test(tokens[i])) {
364
+ const name = nameOf(tokens[i]);
365
+ if (GIT_INDEX_VARS.has(name)) {
366
+ enfLog.warn("git_index_override", "source", name);
367
+ return name;
368
+ }
369
+ i++;
370
+ }
371
+ if (tokens[i] !== "env") break;
372
+ i++;
373
+ // env's own options: only these take a separate value, and that value must
374
+ // not be read as an assignment or as the command. `-i` takes none, so
375
+ // treating every option as value-taking would swallow the assignment after it.
376
+ while (i < tokens.length && tokens[i].startsWith("-") && tokens[i] !== "-") {
377
+ const opt = tokens[i].split("=")[0];
378
+ if (!tokens[i].includes("=") && ENV_VALUE_OPTIONS.has(opt)) i++;
379
+ i++;
380
+ }
381
+ }
382
+ const g = tokens.indexOf("git");
383
+ if (g === -1) continue;
384
+ for (let k = g + 1; k < tokens.length; k++) {
385
+ const tok = tokens[k];
386
+ if (!tok.startsWith("-") || tok === "-") break;
387
+ const name = nameOf(tok);
388
+ if (GIT_INDEX_OPTIONS.has(name)) {
389
+ enfLog.warn("git_index_override", "source", name);
390
+ return name;
391
+ }
392
+ if (!tok.includes("=") && tokens[k + 1] && !tokens[k + 1].startsWith("-")) k++;
393
+ }
394
+ }
395
+ return null;
396
+ }
397
+
398
+ /**
399
+ * Every directory the command could plausibly run git in.
400
+ *
401
+ * Candidates come from where git actually runs: the directory in effect at each
402
+ * git invocation, plus every `-C` resolved in order from that directory (git
403
+ * applies them one after another), plus any directory a `cd` reached only
404
+ * conditionally. Each subshell gets its own directory stack, because a `cd`
405
+ * inside one cannot move the outer shell — which is also why a `cd` in a
406
+ * subshell contributes a candidate without becoming the running directory.
407
+ *
408
+ * When an environment variable selects the index, the target is unknowable and
409
+ * `envOverride` says so, so the caller can refuse instead of guess.
410
+ */
411
+ export function conflictCandidateDirs(
412
+ command: string,
413
+ sessionCwd: string,
414
+ ): { all: string[]; envOverride: string | null; dynamicPath: string | null } {
415
+ const envOverride = gitIndexOverride(command);
416
+ // A path the shell expands at run time — `$DIR`, `$(pwd)`, a glob — cannot be
417
+ // checked here, so it is reported rather than resolved to a literal. A glob
418
+ // character the command escaped is part of the name, not a pattern.
419
+ const dynamic = (p: string) => /[$*?{]/.test(p.replace(/\\./g, ""));
420
+ let dynamicPath: string | null = null;
421
+ // Per paren depth: is the directory this shell is in one we could not resolve?
422
+ // A subshell's answer dies with the subshell, which is what makes a completed
423
+ // subshell stop mattering.
424
+ const dynamicAt: boolean[] = [false];
425
+ // Set when a command that stages ran in a directory we could not resolve. That
426
+ // is the case worth refusing: we do not know what it staged. A read-only
427
+ // command in the same directory is not, so it does not pin the refusal.
428
+ let unresolvedStaging = false;
429
+ const dirs = new Set<string>();
430
+ // running[d] is the directory of the shell at paren depth d.
431
+ const running: string[] = [sessionCwd];
432
+ // OLDPWD: where the shell was before the last change, which is what `cd -`
433
+ // returns to. Each depth has its own, and each starts where the shell did —
434
+ // the shell's own PWD, not just the directory we were invoked from.
435
+ const startPwd = process.env.PWD && process.env.PWD.startsWith("/") ? process.env.PWD : sessionCwd;
436
+ const previous: string[] = [startPwd];
437
+ // What `pushd` displaced, per depth, for `popd` to return to.
438
+ const pushed: string[][] = [[]];
439
+ // `HOME=/x git add a.txt` sends a bare `cd` and a `~` somewhere else, so the
440
+ // command's own assignment wins over this process's environment.
441
+ let home = process.env.HOME ?? "~";
442
+ // Walk the *executable* text: a `cd` inside a `bash -c` script, or inside a
443
+ // command substitution, moves the directory the staging really runs in.
444
+ const scan = executableText(command) || command;
445
+
446
+ for (const seg of unquotedSegments(scan)) {
447
+ // Leaving a subshell discards its directory, and entering a new one starts
448
+ // from the outer shell again — otherwise `(cd clean); (git add x)` would
449
+ // inherit `clean` for the second subshell.
450
+ if (running.length > seg.depth + 1) running.length = seg.depth + 1;
451
+ while (running.length <= seg.depth) running.push(running[running.length - 1]);
452
+ while (previous.length <= seg.depth) previous.push(previous[previous.length - 1]);
453
+ while (pushed.length <= seg.depth) pushed.push([]);
454
+ while (dynamicAt.length <= seg.depth) dynamicAt.push(dynamicAt[dynamicAt.length - 1]);
455
+ const assigned = /(?:^|[\s;&|(])HOME=(\S*)/.exec(seg.text);
456
+ if (assigned) home = assigned[1];
457
+ const target = cdTargetIn(seg.text);
458
+ if (target) {
459
+ if (seg.conditional || seg.backgrounded) {
460
+ // Behind a short-circuit the cd may never run, and a backgrounded cd
461
+ // runs in its own subshell — so in both cases the directory the shell is
462
+ // in *before* it is where a later command actually stages.
463
+ dirs.add(running[seg.depth]);
464
+ }
465
+ const from = running[seg.depth];
466
+ // `cd -` goes to OLDPWD as it stands *before* this change, and only then
467
+ // does the previous directory become where we are now.
468
+ let to: string;
469
+ if (target === "-") to = previous[seg.depth];
470
+ else if (target === "~popd") {
471
+ const stack = pushed[seg.depth];
472
+ to = stack.length ? stack.pop()! : from;
473
+ } else {
474
+ to = applyCd(from, target, home);
475
+ // A bare `pushd` pushes where we are and goes home, the same as a bare
476
+ // `cd`; a `cd` leaves the push stack alone.
477
+ if (/\bpushd\b/.test(seg.text) && !/^\s*(?:if\s+|then\s+|else\s+|elif\s+|do\s+|\(\s*|\{\s*)*pushd\s+\S/.test(seg.text)) {
478
+ pushed[seg.depth].push(from);
479
+ }
480
+ }
481
+ if (seg.backgrounded) {
482
+ // The subshell moves; this shell does not.
483
+ dirs.add(to);
484
+ } else {
485
+ previous[seg.depth] = from;
486
+ running[seg.depth] = to;
487
+ }
488
+ // An expanded target leaves the directory unresolved. A relative move
489
+ // inherits that from where it started; an absolute literal path settles it.
490
+ dynamicAt[seg.depth] =
491
+ dynamic(target) || (!target.startsWith("/") && dynamicAt[seg.depth]);
492
+ if (!seg.conditional && !seg.backgrounded && target.startsWith("/") && !dynamic(target) && !unresolvedStaging) {
493
+ // Once a staging command has already run in an unresolved directory, the
494
+ // path stays reported: the refusal has to name the path we could not read.
495
+ dynamicPath = null;
496
+ }
497
+ if (dynamic(target) && !dynamicPath) dynamicPath = target;
498
+ }
499
+ if (segmentRunsGit(seg.text)) {
500
+ let base = running[seg.depth];
501
+ // `env -C dir git add x` moves git into dir without a shell cd.
502
+ const envChdir = /^\s*(?:VAR=\S*\s*)*env\s+(?:-\S+\s+)*?(?:-C|--chdir)(?:[=\s]+)(\S+)/.exec(seg.text);
503
+ if (envChdir) base = applyCd(base, envChdir[1], home);
504
+ dirs.add(base);
505
+ // Git applies each -C in turn, each relative to the previous one.
506
+ const overrides = gitCsIn(seg.text);
507
+ let cursor = base;
508
+ for (const c of overrides) {
509
+ // An expanded override is the directory this invocation actually runs
510
+ // in, so it is the one worth reporting over an earlier cd target.
511
+ if (dynamic(c)) dynamicPath = c;
512
+ // A directory literally named `-` is ordinary; only the shell's own
513
+ // `cd -` means the previous directory.
514
+ cursor = applyCd(cursor, c, home, false);
515
+ dirs.add(cursor);
516
+ }
517
+ // Where this invocation actually runs decides whether it is unresolved. A
518
+ // final absolute override names the worktree outright, so a directory the
519
+ // shell merely expanded on its way there is irrelevant to it.
520
+ const stages = isConflictResolutionCommand(seg.text);
521
+ const lastOverride = overrides[overrides.length - 1];
522
+ const named = lastOverride !== undefined && !dynamic(lastOverride) && lastOverride.startsWith("/");
523
+ if (stages && !named) {
524
+ if (dynamicAt[seg.depth] || overrides.some((c) => dynamic(c))) unresolvedStaging = true;
525
+ }
526
+ }
527
+ }
528
+ const all = [...dirs];
529
+ // Only a command that staged while the directory was unresolved is worth
530
+ // refusing. Otherwise the path was resolved, or only read from, by the end.
531
+ const unresolved = unresolvedStaging ? dynamicPath : null;
532
+ enfLog.debug("conflict_candidate_dirs", "count", all.length, "env_override", envOverride ?? "none", "dynamic_path", unresolved ?? "none", "unresolved_staging", unresolvedStaging);
533
+ return { all, envOverride, dynamicPath: unresolved };
534
+ }
535
+
62
536
  /** Parse bash command for cd target to resolve the effective working directory (worktree support) */
63
537
  export function resolveEffectiveCwd(command: string, sessionCwd: string): string {
64
- // Match the FIRST cd in the command (at start or after &&, ;, ||)
65
- // First cd sets up the working directory before subsequent commands run.
66
- // Using LAST cd is unsafe — a trailing cd (e.g., git commit && cd /tmp) would
67
- // misattribute the cwd to the wrong directory.
68
- const cdMatch = command.match(/(?:^|[;&|]\s*)cd\s+([^\s;&|]+)/);
69
- if (cdMatch) {
70
- const target = cdMatch[1].replace(/['"]/g, "");
71
- if (target.startsWith("/")) return target;
72
- return join(sessionCwd, target);
73
- }
74
- // Match: git -C /path/to/dir ...
75
- const gitCMatch = command.match(/\bgit\s+-C\s+([^\s]+)/);
76
- if (gitCMatch) {
77
- const target = gitCMatch[1].replace(/['"]/g, "");
78
- if (target.startsWith("/")) return target;
79
- return join(sessionCwd, target);
538
+ // Quoting decides what is syntax, so segments are split outside quotes first.
539
+ // Then the LAST cd before the git invocation, applied in shell order so a
540
+ // chain of relative cds composes. Two cases make the obvious implementations
541
+ // wrong:
542
+ // (cd clean && cd conflicted && git add x) → the later cd wins
543
+ // cd /repo && git commit && cd /tmp → a trailing cd must not
544
+ // With no git invocation to anchor to, the first cd is used, which is the
545
+ // conservative answer for `cd a && cd b && pytest`.
546
+ //
547
+ // A command that can run git in more than one directory (a conditional branch,
548
+ // a completed subshell, several invocations) has no single answer here. That
549
+ // is why conflict enforcement asks conflictCandidateDirs instead of trusting
550
+ // this function's guess.
551
+ const segments = unquotedSegments(command);
552
+ // A segment only counts when it *runs* git: `cd ~/git/proj` merely contains
553
+ // the word, and anchoring on it would drop a real directory change.
554
+ const gitIndex = segments.findIndex((s) => segmentRunsGit(s.text));
555
+ // Only a cd that moves *this* shell counts. A cd inside a subshell, or one
556
+ // behind a short-circuit, runs somewhere else or may not run at all, so
557
+ // applying it sent the guards looking at a directory the shell never entered —
558
+ // `(cd /tmp) && git commit` read /tmp settings, where enforcement is off.
559
+ const targetDepth = gitIndex === -1 ? 0 : segments[gitIndex].depth;
560
+ const running: string[] = [sessionCwd];
561
+ const applied: string[] = [];
562
+ for (let i = 0; i < segments.length; i++) {
563
+ // A trailing cd after the git command did not happen yet.
564
+ if (i === gitIndex) break;
565
+ const seg = segments[i];
566
+ // A segment deeper than the git command runs in a subshell that has already
567
+ // closed by the time git runs, so it cannot have moved this shell. It is
568
+ // skipped rather than ending the walk: a subshell before the command is
569
+ // ordinary, and stopping there would drop every later directory change.
570
+ if (seg.depth > targetDepth) continue;
571
+ while (running.length <= seg.depth) running.push(running[running.length - 1]);
572
+ const target = cdTargetIn(seg.text);
573
+ if (!target || target === "~popd") continue;
574
+ // A change at a shallower depth still counts: `cd /protected && (git commit)`
575
+ // enters /protected first, and the subshell inherits it. Only deeper segments
576
+ // were ruled out above, because those run in a subshell that has closed by
577
+ // the time git runs. Note that a change behind a short-circuit still counts:
578
+ // `cd a && cd b` really does leave the shell in b, and `if cd x; then` leaves
579
+ // it in x too.
580
+ running[seg.depth] = applyCd(running[seg.depth], target);
581
+ applied.push(target);
582
+ // With no git invocation to anchor to, the first cd is the conservative
583
+ // answer for `cd a && cd b && pytest`.
584
+ if (gitIndex === -1) break;
80
585
  }
81
- return sessionCwd;
586
+ // The git segment itself was never walked, so a subshell depth it introduced
587
+ // has no entry yet. It inherits the directory of the shell that spawned it.
588
+ while (running.length <= targetDepth) running.push(running[running.length - 1]);
589
+ let dir = running[targetDepth];
590
+ // Git applies each -C in turn, each relative to the previous one, so
591
+ // `git -C worktree -C nested` lands in worktree/nested.
592
+ const cTargets = gitIndex === -1 ? [] : gitCsIn(segments[gitIndex].text);
593
+ for (const cTarget of cTargets) dir = applyCd(dir, cTarget, process.env.HOME ?? "~", false);
594
+ enfLog.debug("effective_cwd", "dir", dir, "source", cTargets.length ? "git_C" : applied.length ? "cd" : "session", "cds", applied.length);
595
+ return dir;
82
596
  }
83
597
 
84
598
  /** Auto-fix direct python commands (prepend uv run), block pip commands */