pi-orchestrator-config 4.22.2 → 4.22.4

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.
@@ -44,11 +44,20 @@ Before ANY `git commit`, if `review_loop_enforcement` is `true`:
44
44
  {{SETTINGS:dco,commit_trailer,use_worktrees,allow_push_to_protected_branches}}
45
45
 
46
46
  - If `dco` is `true`: add `--signoff` to `git commit`. Skip if already present.
47
- - If `commit_trailer` is a string (e.g. `"Assisted-by"`): append trailer to the commit message.
48
- Format: `TRAILER: PI (MODEL) <noreply@pi.dev>` — replace MODEL with your actual model id
49
- (e.g. `litellm/claude-opus-4-6`). NEVER write the literal string `$PI_MODEL`; quoted heredocs
50
- and single-quoted messages do not expand it and the trailer lands broken. Skip if already present.
51
- If the value contains commas, use the first name.
47
+ - If `commit_trailer` is a string (e.g. `"Assisted-by"`): every commit you make MUST carry that
48
+ trailer, and who writes it depends on the commit shape:
49
+ - `git commit -m "…"` → the enforcement hook appends `TRAILER: PI (<model>)`.
50
+ Do not write it yourself.
51
+ - `echo … | git commit -F -` → the hook appends it. Do not write it yourself.
52
+ - `git commit -F <file>` (message written to a file, including heredoc-to-file) → the hook never
53
+ sees the file's contents, so append the trailer yourself with the already-resolved id:
54
+ `printf '\n\nTRAILER: PI (%s)\n' "$PI_MODEL" >> <file>` and then commit. The shell expands
55
+ `$PI_MODEL` while writing the file, so no placeholder can reach the message.
56
+ Never write a bare `MODEL` placeholder and never leave an unexpanded `$PI_MODEL` in a `-m` message:
57
+ the enforcement hook **refuses** such a commit (it reports the placeholder line back), and it also
58
+ refuses a commit shape it cannot inspect when the message shows no trailer. The fix is always to
59
+ write the trailer yourself with the real id, then commit again.
60
+ Skip only when the message already carries that trailer.
52
61
  - If `use_worktrees` is `true`: BLOCK `git checkout` (except `git checkout -- <file>`) and `git switch`.
53
62
  Use `git worktree add .worktrees/NAME -b BRANCH DEFAULT_BRANCH` instead
54
63
  (detect default branch via `git symbolic-ref refs/remotes/origin/HEAD`, fallback to `main`).
@@ -6,13 +6,14 @@
6
6
  import { createLogger } from "../shared/logger.js";
7
7
  import { createHash } from "node:crypto";
8
8
  import { lstatSync, realpathSync, mkdirSync, rmdirSync } from "node:fs";
9
+ import { homedir } from "node:os";
9
10
  import { execFileSync } from "node:child_process";
10
11
  import * as path from "node:path";
11
12
  import { join } from "node:path";
12
13
  import { getProjectDataDir } from "./utils.js";
13
14
  import { getGlobalSettingsPath, getSettingsPath, loadGlobalSettings, loadProjectSettings } from "./project-settings.js";
14
15
  import { readSettingsFile, writeSettingsFile } from "./settings-tui-helpers.js";
15
- import { DANGEROUS, executableText, getCurrentBranch, isConflictResolutionCommand, segmentRunsGit, tokenize } from "./git-helpers.js";
16
+ import { DANGEROUS, WRAPPER_VALUE_OPTIONS, executableText, getCurrentBranch, isConflictResolutionCommand, segmentRunsGit, tokenize } from "./git-helpers.js";
16
17
 
17
18
  const enfLog = createLogger("enforcement");
18
19
  enfLog.debug("enforcement-helpers module loaded");
@@ -313,7 +314,7 @@ function gitCsIn(segment: string): string[] {
313
314
  }
314
315
 
315
316
  /** Words that run whatever follows them, so an assignment behind one still counts. */
316
- const WRAPPER_WORDS = new Set(["sudo", "command", "exec", "time", "nohup", "nice", "stdbuf", "setsid", "timeout"]);
317
+ const WRAPPER_WORDS = new Set(["sudo", "command", "exec", "time", "nohup", "nice", "stdbuf", "setsid", "timeout", "rtk"]);
317
318
 
318
319
  /** env options whose value is a separate word. */
319
320
  const ENV_VALUE_OPTIONS = new Set([
@@ -324,6 +325,26 @@ const ENV_VALUE_OPTIONS = new Set([
324
325
  const GIT_INDEX_VARS = new Set(["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"]);
325
326
  const GIT_INDEX_OPTIONS = new Set(["--git-dir", "--work-tree", "--namespace"]);
326
327
 
328
+ /** Command words that hand a quoted string to a shell, which then really runs it. */
329
+ const SHELL_WORDS = new Set(["bash", "sh", "zsh", "dash", "ksh", "eval"]);
330
+
331
+ /** True when this segment RUNS a shell (behind any wrapper/assignment/env prefix).
332
+ * A quoted argument containing shell-looking text is not a shell run: only the
333
+ * command word counts. */
334
+ function runsNestedShell(tokens: string[]): boolean {
335
+ for (let i = 0; i < tokens.length; i++) {
336
+ const tok = tokens[i];
337
+ if (WRAPPER_WORDS.has(tok) || tok === "env" || /^[A-Za-z_][A-Za-z0-9_]*=/.test(tok)) continue;
338
+ if (tok.startsWith("-")) {
339
+ // env's own options sit between `env` and the command it runs.
340
+ if (!tok.includes("=") && ENV_VALUE_OPTIONS.has(tok.split("=")[0])) i++;
341
+ continue;
342
+ }
343
+ return SHELL_WORDS.has(tok.replace(/^[()]+/, "").replace(/.*\//, ""));
344
+ }
345
+ return false;
346
+ }
347
+
327
348
  /**
328
349
  * Anything that points git at a repository other than the working directory: the
329
350
  * GIT_DIR/GIT_WORK_TREE/GIT_INDEX_FILE environment variables, or the equivalent
@@ -336,21 +357,47 @@ const GIT_INDEX_OPTIONS = new Set(["--git-dir", "--work-tree", "--namespace"]);
336
357
  * only count in git's global option region, before the subcommand, so
337
358
  * `git add -- --work-tree=x` is a filename too.
338
359
  */
339
- function gitIndexOverride(command: string): string | null {
360
+ /** What a scan looks for: the environment variables, git global options, and
361
+ * `-c` config keys that select the repository or identity git runs with. */
362
+ interface GitOverrideTargets {
363
+ /** Variables that count in assignment position. */
364
+ env: Set<string>;
365
+ /** Long options in git's global region, before the subcommand. */
366
+ flags: Set<string>;
367
+ /** Keys of `-c <key>=<value>` / `--config-env=<key>=<VAR>`. */
368
+ configKeys: Set<string>;
369
+ }
370
+
371
+ const NO_KEYS: Set<string> = new Set();
372
+
373
+ /**
374
+ * The one assignment/option walk behind both git override guards
375
+ * (gitIndexOverride and gitIdentityOverride). Returns a label for what it found,
376
+ * or null.
377
+ *
378
+ * Position matters. An environment variable only counts in *assignment
379
+ * position* — `GIT_DIR=/x git add a.txt`, optionally through `env` — so
380
+ * `git add GIT_INDEX_FILE=notes` stages a file with that name. Options only
381
+ * count in git's global region before the subcommand, so
382
+ * `git add -- --work-tree=x` is a filename too.
383
+ */
384
+ function scanGitOverrides(command: string, targets: GitOverrideTargets): string | null {
340
385
  const assignment = /^[A-Za-z_][A-Za-z0-9_]*=/;
386
+ const nameOf = (tok: string) => tok.split("=")[0];
341
387
  for (const seg of unquotedSegments(command)) {
342
388
  const tokens = tokenize(seg.text);
343
- const nameOf = (tok: string) => tok.split("=")[0];
344
389
  let i = 0;
345
390
  // `export GIT_INDEX_FILE=/x` sets it for later commands in the same shell,
346
391
  // so it points git at another index just as an inline assignment does. This
347
392
  // segment does not run git itself, so it is checked before that requirement.
348
- if (tokens[i] === "export") {
393
+ // The executable view of a nested shell wraps a segment in `(`, so `(export`
394
+ // is the same word.
395
+ if (tokens[i]?.replace(/^\(+/, "") === "export") {
349
396
  i++;
350
397
  while (i < tokens.length) {
351
398
  const name = nameOf(tokens[i]);
352
- if (GIT_INDEX_VARS.has(name)) {
353
- enfLog.warn("git_index_override", "source", name, "via", "export");
399
+ if (targets.env.has(name)) {
400
+ enfLog.warn("git_override", "source", name, "via", "export");
354
401
  return name;
355
402
  }
356
403
  i++;
@@ -358,17 +405,30 @@ function gitIndexOverride(command: string): string | null {
358
405
  continue;
359
406
  }
360
407
  // `echo GIT_INDEX_FILE=/x` only prints the name. An override is something
361
- // git is actually run with, so the segment has to run git for it to count.
362
- if (!segmentRunsGit(seg.text)) continue;
408
+ // git is actually run with, so the segment has to run git for it to count —
409
+ // directly, or through a nested shell that this segment RUNS
410
+ // (`GIT_AUTHOR_EMAIL=x bash -c 'git commit'`), whose assignment lives here
411
+ // but whose git runs inside. A quoted argument merely MENTIONING a command
412
+ // (`gh pr create --body "…git -c user.email=x commit…"`) is prose: the
413
+ // segment runs `gh`, not a shell, so it is not followed.
414
+ if (!segmentRunsGit(seg.text) && !(runsNestedShell(tokens) && segmentRunsGit(executableText(seg.text)))) continue;
363
415
  // Leading `VAR=value` assignments, then the same again after `env`. A
364
416
  // wrapper that runs the rest — `sudo`, `command`, `exec`, `time` — sits in
365
417
  // front of both, and can be chained.
366
418
  for (let round = 0; round < 4; round++) {
367
- while (i < tokens.length && WRAPPER_WORDS.has(tokens[i])) i++;
419
+ // A wrapper runs the rest, and its OWN options sit in between: in
420
+ // `rtk -v GIT_AUTHOR_EMAIL=x git commit` the assignment follows rtk's
421
+ // option, so stepping only over the wrapper word hid it (Qodo, PR #996).
422
+ while (i < tokens.length && WRAPPER_WORDS.has(tokens[i])) {
423
+ i++;
424
+ while (i < tokens.length && tokens[i].startsWith("-") && tokens[i] !== "-") {
425
+ i += WRAPPER_VALUE_OPTIONS.has(tokens[i]) ? 2 : 1;
426
+ }
427
+ }
368
428
  while (i < tokens.length && assignment.test(tokens[i])) {
369
429
  const name = nameOf(tokens[i]);
370
- if (GIT_INDEX_VARS.has(name)) {
371
- enfLog.warn("git_index_override", "source", name);
430
+ if (targets.env.has(name)) {
431
+ enfLog.warn("git_override", "source", name);
372
432
  return name;
373
433
  }
374
434
  i++;
@@ -384,14 +444,30 @@ function gitIndexOverride(command: string): string | null {
384
444
  i++;
385
445
  }
386
446
  }
387
- const g = tokens.indexOf("git");
447
+ // A segment can start mid-subshell, so `(git` is the git word too.
448
+ const g = tokens.findIndex((t) => t.replace(/^[()]+|[()]+$/g, "") === "git");
388
449
  if (g === -1) continue;
389
450
  for (let k = g + 1; k < tokens.length; k++) {
390
451
  const tok = tokens[k];
391
452
  if (!tok.startsWith("-") || tok === "-") break;
453
+ // `-c <key>=<value>`, `-c<key>=<value>`, `--config-env=<key>=<VAR>` apply to
454
+ // this one command only — no config file is written, the effect is the same.
455
+ let setting: string | undefined;
456
+ if (tok === "-c") setting = tokens[++k];
457
+ else if (tok.startsWith("-c")) setting = tok.slice(2);
458
+ else if (tok.startsWith("--config-env=")) setting = tok.slice("--config-env=".length);
459
+ if (setting !== undefined) {
460
+ const key = (setting.split("=")[0] ?? "").toLowerCase();
461
+ if (targets.configKeys.has(key)) {
462
+ const label = tok.startsWith("--config-env") ? `--config-env ${key}` : `-c ${key}`;
463
+ enfLog.warn("git_override", "source", label);
464
+ return label;
465
+ }
466
+ continue;
467
+ }
392
468
  const name = nameOf(tok);
393
- if (GIT_INDEX_OPTIONS.has(name)) {
394
- enfLog.warn("git_index_override", "source", name);
469
+ if (targets.flags.has(name)) {
470
+ enfLog.warn("git_override", "source", name);
395
471
  return name;
396
472
  }
397
473
  if (!tok.includes("=") && tokens[k + 1] && !tokens[k + 1].startsWith("-")) k++;
@@ -400,6 +476,168 @@ function gitIndexOverride(command: string): string | null {
400
476
  return null;
401
477
  }
402
478
 
479
+ function gitIndexOverride(command: string): string | null {
480
+ const found = scanGitOverrides(command, { env: GIT_INDEX_VARS, flags: GIT_INDEX_OPTIONS, configKeys: NO_KEYS });
481
+ if (found) enfLog.warn("git_index_override", "source", found);
482
+ return found;
483
+ }
484
+
485
+ /** Environment variables that set who a commit is recorded as. */
486
+ const GIT_IDENTITY_VARS = new Set([
487
+ "GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL",
488
+ ]);
489
+
490
+ /** Git config keys that set who a commit is recorded as. */
491
+ const GIT_IDENTITY_KEYS = new Set([
492
+ "user.name", "user.email", "author.name", "author.email", "committer.name", "committer.email",
493
+ ]);
494
+
495
+ /** `git config` flags that only READ: `git config --get user.email` changes nothing. */
496
+ const CONFIG_READ_FLAGS = new Set(["--get", "--get-all", "--get-regexp", "--list", "-l"]);
497
+
498
+ /** `git config` flags that remove a key: no value follows, yet the identity moves. */
499
+ const CONFIG_UNSET_FLAGS = new Set(["--unset", "--unset-all"]);
500
+
501
+ /**
502
+ * True when a bash command WRITES into a path matching `targetSrc` (a regex
503
+ * source): a redirect (`>`, `>>`, `>|`) or `tee`/`sponge` taking it as an
504
+ * argument. Deliberately NARROW (user decision on issue #949 R3): broad
505
+ * write-verb co-occurrence taxed legitimate work and any text guard is
506
+ * bypassable anyway — this catches the lazy one-liner.
507
+ *
508
+ * Redirect STRUCTURE is read from the prose-masked command; quoted targets are
509
+ * invisible once masked, so those are matched on the raw command (Qodo #950 R5).
510
+ */
511
+ export function bashWritesToPath(command: string, maskedCommand: string, targetSrc: string): boolean {
512
+ // `tee -a <path>` / `tee --append <path>`: tee's own options sit between it and
513
+ // the path it writes.
514
+ const STRUCTURAL = String.raw`>+\|?\s*\S*${targetSrc}|(?:^|[\s;&|(])(?:tee|sponge)(?:\s+-[A-Za-z-]+)*\s+\S*${targetSrc}`;
515
+ const QUOTED = String.raw`>+\s*["'][^"']*${targetSrc}|(?:^|[\s;&|(])(?:tee|sponge)(?:\s+-[A-Za-z-]+)*\s+["'][^"']*${targetSrc}`;
516
+ let result = new RegExp(STRUCTURAL, "i").test(maskedCommand) || new RegExp(QUOTED, "i").test(command);
517
+ if (!result) {
518
+ // A nested shell's script is code, not prose, so a redirect inside it
519
+ // (`bash -c 'echo x >> .git/config'`) still writes the file. Only segments
520
+ // that RUN a shell are read this way — a quoted argument merely describing
521
+ // a command stays prose (Qodo, PR #996).
522
+ for (const seg of unquotedSegments(command)) {
523
+ if (!runsNestedShell(tokenize(seg.text))) continue;
524
+ if (new RegExp(STRUCTURAL, "i").test(executableText(seg.text))) {
525
+ result = true;
526
+ break;
527
+ }
528
+ }
529
+ }
530
+ enfLog.debug("bash_writes_to_path", targetSrc, result);
531
+ return result;
532
+ }
533
+
534
+
535
+ const IDENTITY_TARGETS: GitOverrideTargets = {
536
+ env: GIT_IDENTITY_VARS,
537
+ flags: NO_KEYS,
538
+ configKeys: GIT_IDENTITY_KEYS,
539
+ };
540
+
541
+ /**
542
+ * Anything that sets the identity a commit is recorded under (issue #995): the
543
+ * `GIT_AUTHOR_*` / `GIT_COMMITTER_*` environment variables in assignment
544
+ * position, a
545
+ * per-command `git -c user.name=...`, a `git config` WRITE of an identity key,
546
+ * or `git commit --author`. The identity belongs to the human — an agent that
547
+ * can change it forges commits in someone else's name.
548
+ *
549
+ * Returns a label of what was found, or null.
550
+ *
551
+ * shortcut: a `-c` key held in a shell variable (`-c "$VAR=x"`) does not match,
552
+ * and a quoted value nested two shells deep is seen only if the outer view keeps
553
+ * it parseable — upgrade if either shows up in the wild.
554
+ */
555
+ export function gitIdentityOverride(command: string): string | null {
556
+ // A nested shell string (`bash -c '...'`, `eval`) really runs, so both views
557
+ // are checked: the raw text keeps quoted option VALUES parseable
558
+ // (`-c "user.name=x"`), the executable view exposes what a nested shell runs.
559
+ const executable = executableText(command);
560
+ for (const text of executable && executable !== command ? [command, executable] : [command]) {
561
+ const found = scanGitOverrides(text, IDENTITY_TARGETS) ?? identityWritesIn(text);
562
+ if (found) {
563
+ enfLog.warn("git_identity_override", "source", found);
564
+ return found;
565
+ }
566
+ }
567
+ enfLog.debug("git_identity_override", "none", command.length);
568
+ return null;
569
+ }
570
+
571
+ /**
572
+ * The two identity writes the prefix scan cannot see: `git config <identity key>
573
+ * <value>` (written to disk for every later commit) and `git commit --author`
574
+ * (this one commit recorded as somebody else).
575
+ */
576
+ function identityWritesIn(command: string): string | null {
577
+ // `git config user.email x` — the identity written to disk for every later commit.
578
+ for (const region of gitSubArgRegions(command, "config")) {
579
+ // Tokens come from the RAW region: tokenize() drops the quotes but keeps a
580
+ // quoted value as one token, so `git config user.email "a@b"` and
581
+ // `git config "user.email" a@b` are both seen, while a value that merely
582
+ // MENTIONS the key stays a single token and is never a key (Qodo, PR #996).
583
+ const tokens = tokenize(region);
584
+ if (tokens.some((t) => CONFIG_READ_FLAGS.has(t))) continue;
585
+ const at = tokens.findIndex((t) => GIT_IDENTITY_KEYS.has(t.toLowerCase()));
586
+ if (at === -1) continue;
587
+ // A key alone is a READ (`git config user.email` prints it): only a value
588
+ // after it, or an unset, changes the identity.
589
+ const writes = tokens.some((t) => CONFIG_UNSET_FLAGS.has(t))
590
+ || tokens.slice(at + 1).some((t) => !t.startsWith("-"));
591
+ if (writes) return `git config ${tokens[at].toLowerCase()}`;
592
+ }
593
+ // `git commit --author` — this one commit is recorded as somebody else.
594
+ for (const region of gitSubArgRegions(command, "commit")) {
595
+ // Raw region again: `--author 'A <a@b>'` and `"--author" x` are options the
596
+ // shell passes to git, while a `-m` message mentioning --author is ONE token.
597
+ const tokens = tokenize(region);
598
+ const at = tokens.findIndex((t) => t === "--author" || t.startsWith("--author="));
599
+ if (at === -1) continue;
600
+ // A bare `--author` with no value after it sets nothing.
601
+ if (tokens[at].includes("=") || (tokens[at + 1] !== undefined && !tokens[at + 1].startsWith("-"))) {
602
+ return "--author";
603
+ }
604
+ }
605
+ return null;
606
+ }
607
+
608
+ /**
609
+ * A path that IS git's identity configuration: `~/.gitconfig`, the XDG
610
+ * `~/.config/git/config`, or a repository's `.git/config`. Writing one of these
611
+ * changes the identity every later commit is recorded under, so agents are kept
612
+ * away from them entirely.
613
+ * The two home files count only under THIS user's home — a `.gitconfig` in
614
+ * /tmp or a docs fixture is not the identity git reads (Qodo, PR #996).
615
+ */
616
+ /**
617
+ * Regex source matching a git identity config path as WRITTEN in a shell
618
+ * command — `$HOME`/`~`/absolute home forms and repo-local `.git/config`.
619
+ * One source of truth for the tripwire and its tests (Qodo, PR #996).
620
+ */
621
+ export function gitIdentityConfigSrc(): string {
622
+ const homeSrc = homedir().replace(/\\/g, "/").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
623
+ return String.raw`(?:(?:~|\$HOME|\$\{HOME\}|${homeSrc})/(?:\.gitconfig|\.config/git/config)|\.git/config)(?![A-Za-z0-9._-])`;
624
+ }
625
+
626
+ export function isGitIdentityConfigPath(p: string): boolean {
627
+ const norm = p.replace(/\\/g, "/").replace(/\/+$/, "");
628
+ const base = norm.slice(norm.lastIndexOf("/") + 1);
629
+ const dir = norm.slice(0, Math.max(norm.lastIndexOf("/"), 0));
630
+ const home = homedir().replace(/\\/g, "/").replace(/\/+$/, "");
631
+ const inHome = dir === home || dir === "~";
632
+ const result =
633
+ (inHome && base === ".gitconfig")
634
+ || norm === `${home}/.config/git/config`
635
+ || norm === "~/.config/git/config"
636
+ || /(?:^|\/)\.git\/config$/.test(norm);
637
+ enfLog.debug("is_git_identity_config_path", p, result);
638
+ return result;
639
+ }
640
+
403
641
  /**
404
642
  * Every directory the command could plausibly run git in.
405
643
  *
@@ -2039,8 +2277,13 @@ function subArgSpans(command: string, sub: string): Array<{ start: number; end:
2039
2277
  const spans: Array<{ start: number; end: number; text: string; maskedText: string; stripped: string; masked: string }> = [];
2040
2278
  for (const m of masked.matchAll(re)) {
2041
2279
  const start = (m.index ?? 0) + m[0].length;
2042
- const rest = stripped.slice(start);
2043
- const end = rest.search(/[;&|\n]/);
2280
+ // Boundaries are searched on the MASKED text, so a separator inside a quoted
2281
+ // argument is prose, not the end of the invocation: in
2282
+ // `git commit -m "message; more message" --author='A <a@b>'` the author
2283
+ // option belongs to this commit and must stay inside its region (Qodo, PR
2284
+ // #996). Masking is length-preserving, so the offset is valid on the
2285
+ // original string and the region content still comes from it.
2286
+ const end = masked.slice(start).search(/[;&|\n]/);
2044
2287
  const stop = end === -1 ? stripped.length : start + end;
2045
2288
  spans.push({
2046
2289
  start,
@@ -2363,24 +2606,103 @@ function bodyAlreadySigned(text: string): boolean {
2363
2606
  return false;
2364
2607
  }
2365
2608
 
2366
- // Trailer-shaped line: `Token:` at line start (real newline, literal \n
2367
- // escape, or command start), then the PI trailer identity. Anchoring to the
2368
- // line start keeps mid-sentence prose and unrelated compound-command parts
2369
- // untouched (Qodo 988d3a68) — only an actual trailer line is rewritten.
2370
- const unexpandedTrailerLine = /(^|\n|\\n|-m\s+['"])([ \t]*[A-Za-z][A-Za-z0-9-]*:[ \t]*)PI \(\$PI_MODEL\)/g;
2371
-
2372
- /** #932: an agent may write the commit trailer with the literal `$PI_MODEL`
2373
- * (typically inside a quoted heredoc, where the shell cannot expand it).
2374
- * Rewrite the trailer identity to the resolved model id before the commit
2375
- * runs. Returns the fixed command, or null when nothing needed fixing.
2376
- * Scoped to `git commit` commands and to trailer-shaped lines only, so
2377
- * commit-message prose that legitimately mentions `$PI_MODEL` — bare or
2378
- * mid-sentence — and unrelated parts of compound commands are untouched. */
2379
- export function fixUnexpandedCommitTrailer(command: string, modelId: string): string | null {
2380
- if (!command.includes("$PI_MODEL")) return null;
2609
+ // Trailer-shaped line: `Token:` at line start (real newline, literal \n escape, or
2610
+ // command start), then a PI trailer identity that is a PLACEHOLDER rather than a model
2611
+ // id. Detection only — the hook refuses such a commit instead of rewriting text it may
2612
+ // have misparsed (Qodo, PR #1004).
2613
+ const placeholderTrailerLine = /(^|\n|\\n|-m\s+['"])([ \t]*[A-Za-z][A-Za-z0-9-]*:[ \t]*)PI \(\$?\{?PI_MODEL\}?|MODEL\)/g;
2614
+
2615
+ /** The trailer line carrying an unresolved placeholder, or null when the command has none.
2616
+ * A `Token:` anchor keeps prose that merely mentions the placeholder out of the result. */
2617
+ export function findPlaceholderTrailer(command: string): string | null {
2618
+ if (!/(?:PI_MODEL|\bMODEL\b)/.test(command)) return null;
2381
2619
  if (!/\bgit\s+commit\b/.test(command)) return null;
2382
- const fixed = command.replace(unexpandedTrailerLine, `$1$2PI (${modelId})`);
2383
- return fixed === command ? null : fixed;
2620
+ const matches = [...command.matchAll(placeholderTrailerLine)];
2621
+ const found = matches.at(-1);
2622
+ if (!found) {
2623
+ enfLog.debug("placeholder trailer none");
2624
+ return null;
2625
+ }
2626
+ const line = `${(found[2] ?? "").trim()} PI (placeholder)`;
2627
+ enfLog.warn("placeholder trailer found", line);
2628
+ return line;
2629
+ }
2630
+
2631
+ /** Trailers where git itself needs an address: `Co-authored-by` credits a person. */
2632
+ const TRAILERS_REQUIRING_EMAIL = new Set(["co-authored-by"]);
2633
+
2634
+ /** The value appended after `<Trailer>:`. `Assisted-by` needs no address, `Co-authored-by`
2635
+ * does — hosting integrations only credit a co-author through `Name <email>`. */
2636
+ export function commitTrailerIdentity(trailerName: string, modelId: string): string {
2637
+ const needsEmail = TRAILERS_REQUIRING_EMAIL.has(trailerName.trim().toLowerCase());
2638
+ const identity = needsEmail ? `PI (${modelId}) <noreply@pi.dev>` : `PI (${modelId})`;
2639
+ enfLog.debug("commit_trailer_identity", trailerName, needsEmail);
2640
+ return identity;
2641
+ }
2642
+
2643
+ /** Does a `-F <file>` commit message already carry the trailer?
2644
+ * The caller injects the reader so the decision is testable without touching disk —
2645
+ * the hook reads the file, it does not guess from the command text (Qodo, PR #1004). */
2646
+ export function messageFileTrailerState(
2647
+ filePath: string,
2648
+ trailerName: string,
2649
+ read: (path: string) => string,
2650
+ ): "has" | "missing" | "unreadable" {
2651
+ try {
2652
+ const state = commandHasTrailerByName(read(filePath).slice(0, 64_000), trailerName) ? "has" : "missing";
2653
+ enfLog.debug("message_file_trailer", filePath, state);
2654
+ return state;
2655
+ } catch (error: any) {
2656
+ // warn, not debug: trailer verification could not inspect the message, which is a
2657
+ // degraded guarantee an operator needs to see (Qodo, PR #1004).
2658
+ enfLog.warn("message_file_trailer unreadable", filePath, String(error?.message ?? error));
2659
+ return "unreadable";
2660
+ }
2661
+ }
2662
+
2663
+ /** Append `<Trailer>: PI (<model>)` to the message of a real commit invocation.
2664
+ * Covers the two shapes whose message the hook can see — `echo … | git commit -F -`
2665
+ * and `git commit -m "…"` — and returns null when the trailer is already there or
2666
+ * the shape is not covered. Pure so the injection path is testable without a hook. */
2667
+ export function injectCommitTrailer(command: string, trailerName: string, modelId: string): string | null {
2668
+ const identity = commitTrailerIdentity(trailerName, modelId);
2669
+ // Pattern A: echo "..." | git commit -F -
2670
+ const pipeIdx = command.lastIndexOf("|");
2671
+ if (pipeIdx !== -1 && /git\s+commit\s+.*-F\s*-/.test(command.slice(pipeIdx))) {
2672
+ const echoPart = command.slice(0, pipeIdx);
2673
+ const gitPart = command.slice(pipeIdx);
2674
+ const lastQuoteIdx = Math.max(echoPart.lastIndexOf('"'), echoPart.lastIndexOf("'"));
2675
+ if (lastQuoteIdx > 0) {
2676
+ if (commandHasTrailerByName(echoPart, trailerName)) {
2677
+ enfLog.debug("inject_commit_trailer", "already present", trailerName);
2678
+ return null;
2679
+ }
2680
+ const quoteChar = echoPart[lastQuoteIdx];
2681
+ const escaped = quoteChar === "'"
2682
+ ? `${escapeForSingleQuote(trailerName)}: ${identity}`
2683
+ : `${escapeForDoubleQuote(trailerName)}: ${identity}`;
2684
+ enfLog.info("inject_commit_trailer", "piped", trailerName);
2685
+ return echoPart.slice(0, lastQuoteIdx) + `\\n\\n${escaped.replace(/\n/g, "\\n")}` + echoPart.slice(lastQuoteIdx) + gitPart;
2686
+ }
2687
+ return null;
2688
+ }
2689
+ // Pattern B: git commit -m "..." or git commit -m '...'
2690
+ const mFlagMatch = command.match(/git\s+commit\s+.*-m\s+(["'])([\s\S]*?)\1/);
2691
+ if (!mFlagMatch) return null;
2692
+ // Every `-m` is a message paragraph, so an existing trailer in ANY of them wins.
2693
+ const allPayloads = [...command.matchAll(/-m\s+(["'])([\s\S]*?)\1/g)].map((m) => m[2] ?? "").join("\n");
2694
+ if (commandHasTrailerByName(allPayloads, trailerName)) {
2695
+ enfLog.debug("inject_commit_trailer", "already present", trailerName);
2696
+ return null;
2697
+ }
2698
+ const quoteChar = mFlagMatch[1];
2699
+ const escaped = quoteChar === "'"
2700
+ ? `${escapeForSingleQuote(trailerName)}: ${identity}`
2701
+ : `${escapeForDoubleQuote(trailerName)}: ${identity}`;
2702
+ const fullMatch = mFlagMatch[0];
2703
+ const insertPos = command.indexOf(fullMatch) + fullMatch.length - 1;
2704
+ enfLog.info("inject_commit_trailer", "inline", trailerName);
2705
+ return command.slice(0, insertPos) + `\n\n${escaped}` + command.slice(insertPos);
2384
2706
  }
2385
2707
 
2386
2708
  /** Keep the first standalone valid footer, but use the current operation's model. */
@@ -20,7 +20,8 @@ import { getProjectTmpDir, resolveWorktreeRoot } from "./utils.js";
20
20
  import {
21
21
  conflictCandidateDirs,
22
22
  pushTargetsProtectedBranch,
23
- fixUnexpandedCommitTrailer,
23
+ findPlaceholderTrailer,
24
+ messageFileTrailerState,
24
25
  maskQuotedRegions,
25
26
  isStdinFedShellStatement,
26
27
  } from "./enforcement-helpers.js";
@@ -46,12 +47,17 @@ import {
46
47
  checkPythonPipBlock,
47
48
  checkRemoteExecBlock,
48
49
  checkTempFileEnforcement,
50
+ bashWritesToPath,
49
51
  gitAddPathspecs,
52
+ gitIdentityConfigSrc,
53
+ gitIdentityOverride,
50
54
  gitSubArgRegions,
51
55
  hasGitAddBulk,
52
56
  hasGitAddForce,
57
+ injectCommitTrailer,
53
58
  injectSignoff,
54
59
  injectGhBodySignature,
60
+ isGitIdentityConfigPath,
55
61
  isReadOnlyStatement,
56
62
  isRealGitCommitOrPush,
57
63
  isRmInProjectTmp,
@@ -114,9 +120,23 @@ function writeRepeatState(state: { lastCmd: string; count: number }): void {
114
120
 
115
121
 
116
122
 
123
+ /** Placeholder strings that are never a real model id — printing one in a trailer ships a bug. */
124
+ const PLACEHOLDER_MODEL_IDS = new Set(["model", "pi_model", "$pi_model", "${pi_model}", "unknown"]);
125
+
117
126
  /** Resolve the model id used in commit/PR trailers (env first, then ctx). */
118
- function commitTrailerModelId(ctx: any): string {
119
- return (process.env.PI_PRIMARY_MODEL || process.env.PI_MODEL || (ctx as any).model?.id || "unknown").replace(/[^a-zA-Z0-9._:@/ -]/g, "").trim() || "unknown";
127
+ export function commitTrailerModelId(ctx: any): string {
128
+ for (const raw of [process.env.PI_PRIMARY_MODEL, process.env.PI_MODEL, (ctx as any).model?.id]) {
129
+ const trimmed = (raw ?? "").trim();
130
+ if (!trimmed) continue;
131
+ if (PLACEHOLDER_MODEL_IDS.has(trimmed.toLowerCase())) {
132
+ log.debug("commit_trailer: skipped placeholder model id", trimmed);
133
+ continue;
134
+ }
135
+ const clean = trimmed.replace(/[^a-zA-Z0-9._:@/ -]/g, "").trim();
136
+ if (clean) return clean;
137
+ }
138
+ log.warn("commit_trailer: no model id resolved — trailer identity is unknown");
139
+ return "unknown";
120
140
  }
121
141
 
122
142
  /** Inject commit trailer into command (Assisted-by, Co-authored-by, etc.) */
@@ -124,18 +144,20 @@ function commitTrailerModelId(ctx: any): string {
124
144
  let bridgedUi: ReturnType<typeof createBridgedUi> | null = null;
125
145
 
126
146
  async function handleCommitTrailer(command: string, event: any, ctx: any, gitCwd: string): Promise<EnforcementResult> {
127
- // #932: rewrite a literal `$PI_MODEL` in the trailer identity — an agent
128
- // following the git-expert trailer instruction may write the placeholder
129
- // verbatim (quoted heredocs and single-quoted -m strings never expand it).
130
- // This runs BEFORE the setting check and pattern matching so heredoc/-F
131
- // commits — which the injection patterns below do not cover — still get a
132
- // correct trailer, and the dedup check below sees the already-fixed name.
133
- const literalModelId = commitTrailerModelId(ctx);
134
- const fixedCommand = fixUnexpandedCommitTrailer(command, literalModelId);
135
- if (fixedCommand !== null) {
136
- log.info("commit_trailer: replaced unexpanded $PI_MODEL literal with model id", literalModelId);
137
- command = fixedCommand;
138
- event.input.command = fixedCommand;
147
+ // A placeholder trailer is REFUSED, never rewritten: the hook cannot safely tell
148
+ // command text from message prose (a commit message quoting the very shape it
149
+ // parses, a `;` inside quotes, indented examples), and rewriting the wrong span
150
+ // corrupts a message or edits an unrelated command. The agent has the text, so it
151
+ // fixes it and retries (Qodo, PR #1004).
152
+ const placeholder = findPlaceholderTrailer(command);
153
+ if (placeholder) {
154
+ return {
155
+ block: true,
156
+ reason:
157
+ `⛔ Commit refused: the trailer carries an unresolved placeholder (${placeholder}). ` +
158
+ `Write \`TRAILER: PI (${commitTrailerModelId(ctx)})\` instead of a MODEL/$PI_MODEL placeholder ` +
159
+ "and re-run the commit.",
160
+ };
139
161
  }
140
162
 
141
163
  const trailerSetting = getSetting(gitCwd, "commit_trailer");
@@ -143,7 +165,6 @@ async function handleCommitTrailer(command: string, event: any, ctx: any, gitCwd
143
165
 
144
166
  const modelId = commitTrailerModelId(ctx);
145
167
 
146
- const piIdentity = `PI (${modelId}) <noreply@pi.dev>`;
147
168
  let trailerName: string;
148
169
 
149
170
  if (trailerSetting.includes(",")) {
@@ -180,55 +201,54 @@ async function handleCommitTrailer(command: string, event: any, ctx: any, gitCwd
180
201
  trailerName = trailerSetting;
181
202
  }
182
203
 
183
- // Pattern A: echo "..." | git commit -F -
184
- const pipeIdx = command.lastIndexOf("|");
185
- if (pipeIdx !== -1 && /git\s+commit\s+.*-F\s*-/.test(command.slice(pipeIdx))) {
186
- const echoPart = command.slice(0, pipeIdx);
187
- const gitPart = command.slice(pipeIdx);
188
- const lastDoubleQuote = echoPart.lastIndexOf('"');
189
- const lastSingleQuote = echoPart.lastIndexOf("'");
190
- const lastQuoteIdx = Math.max(lastDoubleQuote, lastSingleQuote);
191
- if (lastQuoteIdx > 0) {
192
- // Determine quote context for correct escaping
193
- const quoteChar = echoPart[lastQuoteIdx];
194
- // Scope dedup to the echo/message-construction portion (before the pipe), which
195
- // excludes the git args. Do NOT attempt to slice out the exact quoted substring
196
- // by backward-scanning for a quote — escaped quotes (\") inside the message would
197
- // misparse the boundary and cause a missed trailer -> duplicate injection.
198
- if (commandHasTrailerByName(echoPart, trailerName)) {
199
- log.debug("commit_trailer already present in echo message (by name) — skipping injection", trailerName);
200
- return undefined;
201
- }
202
- const escaped = quoteChar === "'"
203
- ? `${escapeForSingleQuote(trailerName)}: ${piIdentity}`
204
- : `${escapeForDoubleQuote(trailerName)}: ${piIdentity}`;
205
- event.input.command =
206
- echoPart.slice(0, lastQuoteIdx) + `\\n\\n${escaped.replace(/\n/g, "\\n")}` + echoPart.slice(lastQuoteIdx) + gitPart;
207
- }
204
+ const injected = injectCommitTrailer(command, trailerName, modelId);
205
+ if (injected !== null) {
206
+ event.input.command = injected;
207
+ return undefined;
208
208
  }
209
- // Pattern B: git commit -m "..." or git commit -m '...'
210
- else {
211
- const mFlagMatch = command.match(/git\s+commit\s+.*-m\s+(["'])([\s\S]*?)\1/);
212
- if (mFlagMatch) {
213
- const quoteChar = mFlagMatch[1];
214
- // Scope duplicate-by-name detection to the -m message payload only
215
- // (mFlagMatch[2]) — not the whole command — so an unrelated arg/env var
216
- // containing the trailer token does not wrongly skip injection.
217
- const payload = mFlagMatch[2];
218
- if (commandHasTrailerByName(payload, trailerName)) {
219
- log.debug("commit_trailer already present in message (by name) — skipping injection", trailerName);
220
- return undefined;
221
- }
222
- const escaped = quoteChar === "'"
223
- ? `${escapeForSingleQuote(trailerName)}: ${piIdentity}`
224
- : `${escapeForDoubleQuote(trailerName)}: ${piIdentity}`;
225
- const fullMatch = mFlagMatch[0];
226
- const insertPos = command.indexOf(fullMatch) + fullMatch.length - 1;
227
- event.input.command =
228
- command.slice(0, insertPos) + `\n\n${escaped}` + command.slice(insertPos);
209
+ // `git commit -F <file>`: the message is a file we CAN read, so check it instead of
210
+ // guessing. Refusing a message that already carries the trailer would block the very
211
+ // flow the git-expert instruction describes (Qodo, PR #1004).
212
+ const fileMessage = command.match(/git\s+commit\b[^\n]*?-F\s+(?!-)(["']?)([^\s"']+)\1/);
213
+ if (fileMessage && fileMessage[2]) {
214
+ // The read happens BEFORE the command runs, so a command that writes the same file
215
+ // would be verified against its OLD contents while git commits the new ones. Refuse
216
+ // that shape instead of trusting the stale read (Qodo, PR #1004).
217
+ const filePathSrc = fileMessage[2].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
218
+ if (bashWritesToPath(command, maskQuotedRegions(command), filePathSrc)) {
219
+ log.warn("commit_trailer: message file is written by the same command", fileMessage[2]);
220
+ return {
221
+ block: true,
222
+ reason:
223
+ `⛔ Commit refused: this command writes ${fileMessage[2]} and commits it in the same call, ` +
224
+ "so the message cannot be verified before it is read. Write the message (with the trailer) in one call and commit in the next.",
225
+ };
226
+ }
227
+ const candidate = path.isAbsolute(fileMessage[2]) ? fileMessage[2] : join(gitCwd, fileMessage[2]);
228
+ const state = messageFileTrailerState(candidate, trailerName, (p) => readFileSync(p, "utf8"));
229
+ if (state === "has") {
230
+ log.debug("commit_trailer: message file already carries the trailer", candidate);
231
+ return undefined;
232
+ }
233
+ if (state === "missing") {
234
+ return {
235
+ block: true,
236
+ reason:
237
+ `⛔ Commit refused: the message file ${fileMessage[2]} has no \`${trailerName}\` trailer. ` +
238
+ `Append it with the real id — \`printf '\\n\\n" + trailerName + ": PI (%s)\\n' \"$PI_MODEL\" >> " + fileMessage[2] + "\` — then re-run the commit.`,
239
+ };
229
240
  }
230
241
  }
231
-
242
+ // Other opaque shapes must carry the trailer already; refusing beats guessing.
243
+ if (!commandHasTrailerByName(command, trailerName)) {
244
+ return {
245
+ block: true,
246
+ reason:
247
+ `⛔ Commit refused: no \`${trailerName}\` trailer found and this commit shape is opaque to the hook ` +
248
+ `(the message is built in a way it cannot inspect). Add \`${trailerName}: PI (${modelId})\` to the message ` +
249
+ "yourself — e.g. `printf '\\n\\n" + trailerName + ": PI (%s)\\n' \"$PI_MODEL\" >> <file>` for `-F <file>` — then re-run the commit.",
250
+ };
251
+ }
232
252
  return undefined;
233
253
  }
234
254
 
@@ -755,6 +775,17 @@ export function registerEnforcement(pi: ExtensionAPI, inContainer?: boolean): vo
755
775
  "\u26d4 Direct modification of pi-config-settings blocked — it carries the dangerous-command allowlist. Change settings via the /pi-config-settings command or your own editor, never agent tools.",
756
776
  };
757
777
  }
778
+ // Git identity config is off-limits to agents too (issue #995): the
779
+ // identity a commit is recorded under belongs to the human, so no agent
780
+ // writes it by hand — same alias-proof resolution as the settings guard.
781
+ if (isGitIdentityConfigPath(resolved) || isGitIdentityConfigPath(toReal(resolved))) {
782
+ return {
783
+ block: true,
784
+ reason:
785
+ "\u26d4 Git identity config is off-limits to agents. The git identity belongs to the human — " +
786
+ "if it is wrong, tell the user to fix their own git config.",
787
+ };
788
+ }
758
789
  }
759
790
  }
760
791
 
@@ -788,12 +819,7 @@ export function registerEnforcement(pi: ExtensionAPI, inContainer?: boolean): vo
788
819
  const maskedCmd = maskQuotedRegions(command);
789
820
  if (
790
821
  cmdLower.includes("pi-config-settings") &&
791
- (/>+\|?\s*\S*pi-config-settings/i.test(maskedCmd) ||
792
- /(?:^|[\s;&|(])(?:tee|sponge)\s+\S*pi-config-settings/i.test(maskedCmd) ||
793
- // Quoted redirect/tee TARGETS are invisible in the masked text —
794
- // catch them on the raw command (Qodo PR #950 R5).
795
- />+\s*["'][^"']*pi-config-settings/i.test(command) ||
796
- /(?:^|[\s;&|(])(?:tee|sponge)\s+["'][^"']*pi-config-settings/i.test(command))
822
+ bashWritesToPath(command, maskedCmd, "pi-config-settings")
797
823
  ) {
798
824
  return {
799
825
  block: true,
@@ -802,6 +828,21 @@ export function registerEnforcement(pi: ExtensionAPI, inContainer?: boolean): vo
802
828
  };
803
829
  }
804
830
 
831
+ // Block bash WRITES targeting a git identity config file (issue #995) —
832
+ // same narrow tripwire shape as the settings one above. The home files
833
+ // count only under THIS user's home: a `.gitconfig` in /tmp is not the
834
+ // identity git reads (Qodo, PR #996). One pattern, shared with the tests.
835
+ const IDENTITY_CONFIG_SRC = gitIdentityConfigSrc();
836
+ if (bashWritesToPath(command, maskedCmd, IDENTITY_CONFIG_SRC)) {
837
+ log.error("git_identity_config_write_blocked");
838
+ return {
839
+ block: true,
840
+ reason:
841
+ "\u26d4 Bash command writing a git identity config (~/.gitconfig, ~/.config/git/config, .git/config) blocked. " +
842
+ "The git identity belongs to the human — if it is wrong, tell the user to fix their own git config.",
843
+ };
844
+ }
845
+
805
846
  // Block repeated identical commands (polling-by-spam) — orchestrator only.
806
847
  // Skip on acpx: async LLM agents are unavailable; foreground polls are expected.
807
848
  const parentSupportsAsyncLlm = supportsAsyncLlm(
@@ -896,6 +937,23 @@ export function registerEnforcement(pi: ExtensionAPI, inContainer?: boolean): vo
896
937
  }
897
938
  }
898
939
 
940
+ // The git identity belongs to the human (issue #995): an agent that can set
941
+ // user.name/user.email can forge a commit in someone else's name. Checked
942
+ // before the git-expert split below so it holds for EVERY agent, and before
943
+ // any git-repo lookup so it holds outside a repo too.
944
+ const identityOverride = gitIdentityOverride(command);
945
+ if (identityOverride) {
946
+ // error, not warn: the command was refused, not recovered from.
947
+ log.error("git_identity_override_blocked", "source", identityOverride);
948
+ return {
949
+ block: true,
950
+ reason:
951
+ `\u26d4 Git identity override blocked (${identityOverride}). The git identity belongs to the human: ` +
952
+ "never set user.name/user.email, GIT_AUTHOR_*/GIT_COMMITTER_*, --author, or `git config user.*`. " +
953
+ "If the identity is wrong, tell the user to fix their own git config.",
954
+ };
955
+ }
956
+
899
957
  // Enforce git commit/push only via git-expert agent.
900
958
  // Use isRealGitCommitOrPush (heredoc-stripped + command-position aware) so that
901
959
  // file CONTENT / string args / paths that merely MENTION "git commit" do not trip
@@ -771,7 +771,7 @@ const GIT_EXECUTORS = new Set([
771
771
  ]);
772
772
 
773
773
  /** Wrapper options whose operand names something other than the command. */
774
- const WRAPPER_VALUE_OPTIONS = new Set([
774
+ export const WRAPPER_VALUE_OPTIONS = new Set([
775
775
  "-u", "--user", "-g", "--group", "-h", "--host", "-p", "--prompt", "-r", "--role",
776
776
  "-t", "--type", "-U", "--other-user", "-C", "--chdir", "--preserve-env",
777
777
  ]);
@@ -811,15 +811,36 @@ export function segmentRunsGit(segment: string): boolean {
811
811
  return verdict;
812
812
  }
813
813
 
814
- function classifySegment(segment: string): boolean {
815
- const words = tokenize(segment.trim());
814
+ /**
815
+ * The index of the word that runs the segment's command: skips control keywords,
816
+ * inline assignments, and TRANSPARENT prefixes whose own options sit before the
817
+ * command they run (`rtk -v git add`). Every consumer that needs "what does this
818
+ * segment actually run" goes through here, so a prefix is never handled in one
819
+ * parser and forgotten in another (Qodo, PR #996).
820
+ */
821
+ function skipToCommandWord(words: string[]): number {
816
822
  let k = 0;
817
823
  while (k < words.length) {
818
824
  // `function NAME { ... }` puts the name between the keyword and the body.
819
825
  if (words[k] === "function") { k += 2; continue; }
820
826
  if (SEGMENT_SKIP.has(words[k]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[k])) { k += 1; continue; }
827
+ // rtk runs exactly the command after it, so the next word decides. It is not
828
+ // an executor: `rtk grep git add f` runs grep, it does not stage.
829
+ if (words[k] === "rtk") {
830
+ k += 1;
831
+ while (k < words.length && words[k].startsWith("-") && words[k] !== "-") {
832
+ k += WRAPPER_VALUE_OPTIONS.has(words[k]) ? 2 : 1;
833
+ }
834
+ continue;
835
+ }
821
836
  break;
822
837
  }
838
+ return k;
839
+ }
840
+
841
+ function classifySegment(segment: string): boolean {
842
+ const words = tokenize(segment.trim());
843
+ const k = skipToCommandWord(words);
823
844
  if (k < words.length) {
824
845
  // A segment can start mid-subshell, so `(git` is still the git command.
825
846
  const head = words[k].replace(/^[()]+|[()]+$/g, "");
@@ -847,13 +868,7 @@ function executedGitCommands(cmd: string): string[][] {
847
868
  const kept: string[][] = [];
848
869
  for (const raw of cmd.split(/[\n;&|()]+/)) {
849
870
  const words = tokenize(raw.trim());
850
- let k = 0;
851
- while (k < words.length) {
852
- // `function NAME { ... }` puts the name between the keyword and the body.
853
- if (words[k] === "function") { k += 2; continue; }
854
- if (SEGMENT_SKIP.has(words[k]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[k])) { k += 1; continue; }
855
- break;
856
- }
871
+ const k = skipToCommandWord(words);
857
872
  if (k >= words.length) continue;
858
873
  // A segment can start mid-subshell, so `(git` is still the git command.
859
874
  const head = words[k].replace(/^[()]+|[()]+$/g, "");
@@ -893,10 +908,8 @@ const GLOBAL_LONG_VALUE_OPTIONS = new Set(["--git-dir", "--work-tree", "--namesp
893
908
  */
894
909
  /** The index of the word that runs git, or -1. */
895
910
  function gitWordIndex(words: string[]): number {
896
- let k = 0;
897
- while (k < words.length) {
898
- if (words[k] === "function") { k += 2; continue; }
899
- if (SEGMENT_SKIP.has(words[k]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[k])) { k += 1; continue; }
911
+ const k = skipToCommandWord(words);
912
+ if (k < words.length) {
900
913
  const head = words[k].replace(/^[()]+|[()]+$/g, "");
901
914
  if (isGitWord(head)) return k;
902
915
  // A wrapper runs whatever follows it. `command -v git` prints the path
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-orchestrator-config",
3
- "version": "4.22.2",
3
+ "version": "4.22.4",
4
4
  "description": "Orchestrator pattern for pi: specialist subagents, enforcement, code review loop, workflow commands",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -34,6 +34,8 @@ Outside slash commands, never use edit, write, or bash directly (except `codemod
34
34
 
35
35
  ## Before Implementation (MANDATORY)
36
36
 
37
+ **For 2+ distinct actions, your first action is one `TaskBulkCreate` listing every step** — before the first edit or the first agent call (see `60-task-tracking.md`).
38
+
37
39
  Before ANY code changes, run the pre-implementation checklist:
38
40
 
39
41
  → **See the "Pre-Implementation Checklist" section below** — Do NOT skip this step.
@@ -7,58 +7,37 @@
7
7
 
8
8
  ---
9
9
 
10
- ## When to Create Tasks
10
+ ## When Tasks Are Required
11
11
 
12
- **BEFORE starting any multi-step workflow (3+ steps)**, use `TaskCreate` to list ALL steps first.
12
+ The request needs **2+ distinct actions** — then tasks are required:
13
13
 
14
- **Use tasks for:** feature implementation, bug fixes with multiple files,
15
- workflows from `rules/05-issue-first-workflow.md` or `rules/20-code-review-loop.md`,
16
- multi-file refactoring, or any work involving more than 2 actions.
14
+ - more than one file to change, or code plus tests plus docs
15
+ - any workflow from `05-issue-first-workflow.md` or `20-code-review-loop.md`
16
+ - the user lists several things to do ("do X, then Y", numbered items)
17
+ - work conditioned on a future event ("after Qodo approves, deploy and test") — create the tasks now, put the trigger in the description, and block the dependents on it
17
18
 
18
- **Deferred missions count as multi-step work.** When the user assigns work
19
- conditional on a future event ("after X", "once Y", "when Z"), create the
20
- tasks IMMEDIATELY in that same turn — the trigger condition goes in the task
21
- description, and dependent tasks block on the trigger task. Never carry a
22
- deferred mission only in conversation memory: compaction or a session
23
- restart loses it silently.
19
+ **No tasks:** a single-step action, a one-file/one-line change, a question, an explanation, read-only investigation.
24
20
 
25
- - ❌ BAD: "after Qodo approves, deploy and test" → acknowledged, no task
26
- - ✅ GOOD: `TaskCreate("Deploy X locally — trigger: Qodo R6 clean")` + blocked follow-ups
21
+ ## First Action
27
22
 
28
- **Skip tasks for:** single-step actions, questions/explanations, or trivial fixes.
23
+ Plan the whole job in **one `TaskBulkCreate` call** listing every step — not one `TaskCreate` per step, and not after the first edit. `TaskCreate` is for a single step added later.
29
24
 
30
- ---
31
-
32
- ## Task Granularity
25
+ When `TaskCreate`'s own tool description suggests per-step calls, **this rule wins**: one bulk call for the whole job.
33
26
 
34
- Tasks MUST be **detailed and specific** — not high-level summaries.
27
+ ## Granularity
35
28
 
36
- - ❌ BAD: `Implement the fix` / `Review code` / `Create PR`
37
- - ✅ GOOD: `Edit extensions/orchestrator/utils.ts — add timeout parameter to fetchData()` / `Run code review loop (3 async reviewers)` / `Create PR with description`
29
+ One concrete action per task: which file, which change.
38
30
 
39
- ---
31
+ - ❌ `Implement the fix` / `Create PR`
32
+ - ✅ `Edit extensions/orchestrator/utils.ts — add timeout parameter to fetchData()` / `Create PR with the test plan`
40
33
 
41
- ## Task Lifecycle (MANDATORY)
34
+ ## Lifecycle
42
35
 
43
- Create all tasks BEFORE starting work via `TaskCreate`.
44
- Mark each `in_progress` before starting and `completed` immediately after finishing via `TaskUpdate`.
45
- Work through tasks in order — do not skip tasks or start new work while unchecked tasks exist (unless user explicitly pivots).
46
-
47
- ---
36
+ `in_progress` before starting a task, `completed` immediately after. Work in order. Never abandon a task — drop a stale one with `TaskUpdate status: "deleted"`.
48
37
 
49
38
  ## Side Questions
50
39
 
51
- Answer the question, then resume the next unchecked task immediately — the persistent task list tells you exactly where you left off.
52
-
53
- ---
54
-
55
- ## Integration & Key Rules
56
-
57
- Task tracking works alongside existing workflow rules — include issue-first, code review, and documentation steps as individual tasks.
58
- {{IF:task_reminder_enabled}}
59
- Tasks are code-enforced (reminders after 4+ ignored turns).
60
- {{/IF}}
61
- Never abandon tasks — if scope changes, use `TaskUpdate` with `status: "deleted"` to remove obsolete tasks; the task list is your contract.
40
+ Answer the question, then resume the next unchecked task — the task list says where you left off.
62
41
 
63
42
  ---
64
43