pi-orchestrator-config 4.2.0 → 4.3.2

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.
Files changed (100) hide show
  1. package/README.md +76 -67
  2. package/agents/code-reviewer-docs.md +11 -1
  3. package/agents/code-reviewer-guidelines.md +11 -1
  4. package/agents/code-reviewer-quality.md +11 -1
  5. package/agents/code-reviewer-security.md +11 -1
  6. package/agents/code-reviewer-spec.md +11 -1
  7. package/agents/git-expert.md +42 -3
  8. package/agents/github-expert.md +11 -0
  9. package/extensions/acpx-provider/runtime-models.ts +18 -6
  10. package/extensions/cli-provider/agents/claude.ts +3 -2
  11. package/extensions/cli-provider/agents/cursor.ts +3 -5
  12. package/extensions/cli-provider/agents/gemini.ts +3 -2
  13. package/extensions/cli-provider/discover.ts +7 -7
  14. package/extensions/cli-provider/index.ts +5 -0
  15. package/extensions/cli-provider/runtime-models.ts +18 -6
  16. package/extensions/cli-provider/session-reaper.ts +5 -7
  17. package/extensions/cli-provider/sessions.ts +4 -0
  18. package/extensions/cli-provider/shared/discover-cache.ts +17 -176
  19. package/extensions/coms/coms-p2p.ts +1719 -423
  20. package/extensions/coms/coms-shared.ts +261 -39
  21. package/extensions/coms/coms-wrapper.ts +100 -62
  22. package/extensions/coms/index.ts +8 -4
  23. package/extensions/coms/mixed-turn.ts +26 -0
  24. package/extensions/coms/probe-socket.ts +42 -0
  25. package/extensions/coms/themeMap.ts +0 -1
  26. package/extensions/image-gen/image-gen.ts +19 -1
  27. package/extensions/orchestrator/async-agents.ts +563 -102
  28. package/extensions/orchestrator/async-capability.ts +8 -8
  29. package/extensions/orchestrator/cron.ts +15 -9
  30. package/extensions/orchestrator/dreaming.ts +32 -25
  31. package/extensions/orchestrator/enforcement-helpers.ts +459 -17
  32. package/extensions/orchestrator/enforcement-rules.ts +5 -4
  33. package/extensions/orchestrator/enforcement.ts +80 -27
  34. package/extensions/orchestrator/extended-autocomplete.ts +7 -0
  35. package/extensions/orchestrator/git-helpers.ts +2 -2
  36. package/extensions/orchestrator/index.ts +78 -10
  37. package/extensions/orchestrator/markdown-transformer.ts +313 -0
  38. package/extensions/orchestrator/memory-embeddings.ts +36 -34
  39. package/extensions/orchestrator/memory-provenance.ts +29 -25
  40. package/extensions/orchestrator/memory-scoring.ts +23 -18
  41. package/extensions/orchestrator/memory-tools.ts +10 -3
  42. package/extensions/orchestrator/overlay-dashboard.ts +17 -5
  43. package/extensions/orchestrator/parse-model-override.ts +29 -0
  44. package/extensions/orchestrator/pi-config-review-state.ts +57 -33
  45. package/extensions/orchestrator/project-settings.ts +333 -127
  46. package/extensions/orchestrator/resolve-agent-model.ts +24 -13
  47. package/extensions/orchestrator/review-porcelain.ts +4 -0
  48. package/extensions/orchestrator/review-ui.ts +63 -15
  49. package/extensions/orchestrator/rule-placeholders.ts +411 -2
  50. package/extensions/orchestrator/rules.ts +80 -132
  51. package/extensions/orchestrator/settings-tui-helpers.ts +380 -0
  52. package/extensions/orchestrator/settings-tui-submenus.ts +647 -0
  53. package/extensions/orchestrator/settings-tui.ts +636 -0
  54. package/extensions/orchestrator/state-jsonl.ts +431 -0
  55. package/extensions/orchestrator/subagent-tool.ts +157 -59
  56. package/extensions/orchestrator/sync-limit.ts +23 -0
  57. package/extensions/orchestrator/task-lifecycle.ts +77 -67
  58. package/extensions/orchestrator/utils.ts +8 -5
  59. package/extensions/pidash/pidash.ts +193 -156
  60. package/extensions/pidiff/pidiff.ts +96 -38
  61. package/extensions/pidiff/spawn-lock.ts +62 -0
  62. package/extensions/pitasks/index.ts +430 -0
  63. package/extensions/pitasks/reminders.ts +37 -0
  64. package/extensions/pitasks/task-store.ts +467 -0
  65. package/extensions/pitasks/task-tools.ts +375 -0
  66. package/extensions/pitasks/task-widget.ts +181 -0
  67. package/extensions/pitasks/types.ts +38 -0
  68. package/extensions/providers/acpx-driver.ts +12 -2
  69. package/extensions/providers/built-in-drivers.ts +6 -6
  70. package/extensions/providers/claude-driver.ts +1 -1
  71. package/extensions/providers/cursor-cli-driver.ts +1 -1
  72. package/extensions/providers/gemini-driver.ts +1 -1
  73. package/extensions/providers/index.ts +144 -26
  74. package/extensions/providers/initialized-guard.ts +27 -0
  75. package/extensions/providers/restore-default-model.ts +596 -0
  76. package/extensions/providers/session-shutdown.ts +39 -0
  77. package/extensions/shared/coms-active.ts +23 -0
  78. package/extensions/shared/create-runtime-provider.ts +2 -0
  79. package/extensions/shared/daemon-manager.ts +0 -8
  80. package/extensions/shared/file-logger.ts +132 -25
  81. package/extensions/shared/logger.ts +50 -0
  82. package/extensions/shared/models-dev.ts +421 -0
  83. package/extensions/shared/oneshot.ts +116 -0
  84. package/extensions/shared/resolve-binary.ts +191 -0
  85. package/extensions/shared/types.ts +3 -0
  86. package/package.json +9 -3
  87. package/prompts/coderabbit-rate-limit.md +2 -0
  88. package/rules/00-orchestrator-core.md +3 -0
  89. package/rules/10-agent-routing.md +12 -1
  90. package/rules/20-code-review-loop.md +52 -25
  91. package/rules/30-prompt-templates.md +4 -0
  92. package/rules/35-memory.md +3 -3
  93. package/rules/40-critical-rules.md +3 -0
  94. package/rules/55-coms-protocol.md +84 -18
  95. package/rules/60-task-tracking.md +2 -0
  96. package/settings-keys.json +563 -0
  97. package/templates/coms-feature-manager-prompt.md +4 -6
  98. package/extensions/coms/coms-net-server.ts +0 -1642
  99. package/extensions/coms/coms-net-wrapper.ts +0 -521
  100. package/extensions/coms/coms-net.ts +0 -1589
@@ -3,10 +3,14 @@
3
3
  * Extracted to allow testing without SDK dependencies.
4
4
  */
5
5
 
6
+ import { createLogger } from "../shared/logger.js";
6
7
  import { realpathSync } from "node:fs";
7
8
  import * as path from "node:path";
8
9
  import { join } from "node:path";
9
- import { DANGEROUS, hasGitSub } from "./git-helpers.js";
10
+ import { DANGEROUS, getCurrentBranch, hasGitSub } from "./git-helpers.js";
11
+
12
+ const enfLog = createLogger("enforcement");
13
+ enfLog.debug("enforcement-helpers module loaded");
10
14
 
11
15
  export type EnforcementResult = { block: true; reason: string } | { autofix: true; modifiedCommand: string; reason: string } | undefined;
12
16
 
@@ -33,6 +37,28 @@ export function escapeForSingleQuote(s: string): string {
33
37
  return s.replace(/'/g, "'\\\''");
34
38
  }
35
39
 
40
+ /**
41
+ * Detect whether a commit command already contains a trailer with the given
42
+ * NAME (e.g. `Assisted-by:`). A commit message must only ever have ONE such
43
+ * trailer, so we match by name — not by the full identity string — to avoid
44
+ * appending a duplicate when the committer (e.g. git-expert) already added one
45
+ * with a DIFFERENT model/identity string (or an unexpanded `$PI_MODEL`).
46
+ *
47
+ * The command embeds the message inside a quoted string where line breaks may
48
+ * appear as REAL newline characters OR as the two-character escaped `\n`
49
+ * sequence (echo -e / printf style). We therefore accept, immediately before
50
+ * the trailer name: start-of-string, a real newline, a literal `\n` two-char
51
+ * sequence, or a quote/whitespace character.
52
+ */
53
+ export function commandHasTrailerByName(command: string, trailerName: string): boolean {
54
+ const escName = trailerName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
55
+ // (^|\n real newline|\\n literal backslash-n|quote/space) + name + ":" + space
56
+ const trailerNameRe = new RegExp(String.raw`(^|\n|\\n|["'\s])` + escName + String.raw`:\s`);
57
+ const result = trailerNameRe.test(command);
58
+ enfLog.debug("commandHasTrailerByName", trailerName, "match", result);
59
+ return result;
60
+ }
61
+
36
62
  /** Parse bash command for cd target to resolve the effective working directory (worktree support) */
37
63
  export function resolveEffectiveCwd(command: string, sessionCwd: string): string {
38
64
  // Match the FIRST cd in the command (at start or after &&, ;, ||)
@@ -159,8 +185,9 @@ export function checkPythonPipBlock(command: string, cmdLower: string): Enforcem
159
185
  export function checkRemoteExecBlock(cmdLower: string): EnforcementResult {
160
186
  const cmdForExecCheck = cmdLower.replace(/<<-?\s*['"]?(\w+)['"]?[\s\S]*/m, "");
161
187
  const remoteExecReason = "\u26d4 Remote script execution is forbidden. Download the script first, audit it with security-auditor, then run if safe.";
162
- if (/\b(curl|wget)\b.*\|(?!\|)\s*(?:sudo\s+(?:-\S+\s+)*|env\s+(?:-\S+\s+)*)*(?:\/\S+\/)*(ba|c|da|[akz]|fi|tc)?sh\b/.test(cmdForExecCheck) ||
163
- /\b(curl|wget)\b.*\|(?!\|)\s*(?:sudo\s+(?:-\S+\s+)*|env\s+(?:-\S+\s+)*)*(python[23]?|perl|ruby|node|deno|bun)\b/.test(cmdForExecCheck)) {
188
+ if (/\b(curl|wget)\b.*\|(?!\|)\s*(?:sudo\s+(?:-\S+\s+)*|env\s+(?:-\S+\s+)*|uv\s+run\s+)*(?:\/\S+\/)*(ba|c|da|[akz]|fi|tc)?sh\b/.test(cmdForExecCheck) ||
189
+ /\b(curl|wget)\b.*\|(?!\|)\s*(?:sudo\s+(?:-\S+\s+)*|env\s+(?:-\S+\s+)*|uv\s+run\s+)*(?:\/\S+\/)*(python[23]?|perl|ruby|node|deno|bun)\b/.test(cmdForExecCheck)) {
190
+ enfLog.debug("remote_exec_block", "pipe");
164
191
  return { block: true, reason: remoteExecReason };
165
192
  }
166
193
  // Match curl/wget anywhere inside process substitution <(...), not just as first token
@@ -168,21 +195,59 @@ export function checkRemoteExecBlock(cmdLower: string): EnforcementResult {
168
195
  if (/\b(?:(?:ba|c|da|[akz]|fi|tc)?sh|python[23]?|perl|ruby|node|deno|bun)\b.*<\((?:"[^"]*"|'[^']*'|[^)"'])*\b(curl|wget)\b/.test(cmdForExecCheck) ||
169
196
  /\bsource\s+<\((?:"[^"]*"|'[^']*'|[^)"'])*\b(curl|wget)\b/.test(cmdForExecCheck) ||
170
197
  /(?:^|[\s;&|])\.\s+<\((?:"[^"]*"|'[^']*'|[^)"'])*\b(curl|wget)\b/.test(cmdForExecCheck)) {
198
+ enfLog.debug("remote_exec_block", "proc-sub");
171
199
  return { block: true, reason: remoteExecReason };
172
200
  }
173
201
  // Block when curl/wget is inside a command substitution AND an execution primitive
174
- // (eval, bash -c, sh -c, etc.) appears anywhere — order-independent.
175
- // Catches: x=$(curl ...); eval "$x", eval "$x"; x=$(curl ...), etc.
176
- // Only matches curl/wget actually inside $() or backticks, not bare curl before unrelated $().
177
- // Detect curl/wget anywhere inside a command substitution, not just as first token.
178
- // Allow quoted strings inside $() to handle quoted ) characters.
179
- // Note: this is intentionally conservative — it matches curl/wget as a word anywhere inside
180
- // the substitution, including in strings like $(echo curl). This trades rare false positives
181
- // for stronger security against obfuscated curl invocations.
182
- const hasCurlSub = /\$\((?:"[^"]*"|'[^']*'|[^)"'])*\b(curl|wget)\b/.test(cmdForExecCheck) || /`[^`]*\b(curl|wget)\b/.test(cmdForExecCheck);
202
+ // (eval, bash -c, sh -c, etc.) CONSUMES that curl output — either inline
203
+ // ($(curl ...)/`curl ...` passed directly to the primitive) or via a variable
204
+ // that was assigned from curl.
205
+ //
206
+ // FALSE-POSITIVE FIX: A SAFE capture `VAR=$(curl ...)` at a statement boundary,
207
+ // followed by an UNRELATED exec primitive that does NOT reference that variable,
208
+ // is not a remote exec. Example (now ALLOWED):
209
+ // code=$(curl -s -w "%{http_code}" https://x/json); python3 -c "import json"
210
+ // Here $code never flows into python3, so there is no remote code execution.
211
+ //
212
+ // To avoid opening a bypass we:
213
+ // 1. Record the set of variable names assigned from curl/wget captures.
214
+ // 2. Strip SAFE curl assignments before the inline-substitution exec check, so a
215
+ // captured-but-unused curl output no longer counts as "curl feeding an exec".
216
+ // 3. Separately block when an exec primitive REFERENCES a curl-assigned variable
217
+ // (e.g. x=$(curl ...); eval "$x") — stripping alone would miss this.
218
+ //
219
+ // Allow quoted strings inside $() to handle quoted ) characters. Matching curl/wget
220
+ // as a word anywhere inside the substitution is intentional — it trades rare false
221
+ // positives for stronger security against obfuscated curl invocations.
222
+ //
223
+ // Safe-assignment shape: VAR=$(...) / VAR=`...` at a statement boundary, terminated
224
+ // by a statement separator / comment / end. CRITICAL: the captured value must NOT
225
+ // itself contain a NESTED command substitution ($( or backtick) — even inside quotes.
226
+ // Otherwise `var=$(bash -c "$(curl ...)")` (a REAL remote exec) would be treated as a
227
+ // simple safe capture and stripped, opening a bypass. Quoted branches therefore
228
+ // forbid `$(` and backtick, and the unquoted branch forbids them too.
229
+ const qDouble = /"(?:(?!\$\()[^"`])*"/.source;
230
+ const qSingle = /'(?:(?!\$\()[^'`])*'/.source;
231
+ const dollarSub = String.raw`\$\((?:` + qDouble + `|` + qSingle + String.raw`|(?!\$\()(?!` + "`" + String.raw`)[^)])*\)`;
232
+ const backtickSub = "`(?:(?!\\$\\()[^`])*`";
233
+ const captureValue = `(?:${dollarSub}|${backtickSub})`;
234
+ const safeAssignmentSrc =
235
+ String.raw`(?:^|(?<=[;&|\n({])\s*)(?:export\s+|declare\s+|local\s+|readonly\s+|typeset\s+)?([a-z_]\w*)=(` +
236
+ captureValue + String.raw`)(?=\s*(?:$|[;&|#\n)}]))`;
237
+ // Collect variable names whose SAFE assignment substitution contains curl/wget.
238
+ const curlVars = new Set<string>();
239
+ for (const m of cmdForExecCheck.matchAll(new RegExp(safeAssignmentSrc, "gi"))) {
240
+ if (/\b(curl|wget)\b/.test(m[2])) curlVars.add(m[1]);
241
+ }
242
+ // Strip ALL safe assignments (curl or not) for the inline-substitution exec check,
243
+ // so a curl output safely captured into a variable no longer counts as an inline
244
+ // substitution feeding an exec primitive.
245
+ const strippedForSub = cmdForExecCheck.replace(new RegExp(safeAssignmentSrc, "gi"), " ");
246
+ // Curl still inside a substitution AFTER stripping safe captures = inline/consumed curl.
247
+ const hasCurlSub = /\$\((?:"[^"]*"|'[^']*'|[^)"'])*\b(curl|wget)\b/.test(strippedForSub) || /`[^`]*\b(curl|wget)\b/.test(strippedForSub);
183
248
  // Anchor exec primitives to command position (start-of-string or after statement separator)
184
249
  // to avoid matching inside URLs/arguments (e.g., https://host/eval)
185
- if (hasCurlSub) {
250
+ if (hasCurlSub || curlVars.size > 0) {
186
251
  // Allow assignment prefixes (VAR=val, VAR="a b"), sudo, and env before exec primitives.
187
252
  // Shell allows VAR="a b" bash -c "cmd" — the assignment sets env for the command.
188
253
  const assignPrefix = /(?:[a-z_]\w*=(?:"[^"]*"|'[^']*'|\S+)\s+)*/.source;
@@ -194,15 +259,59 @@ export function checkRemoteExecBlock(cmdLower: string): EnforcementResult {
194
259
  // Allow leading redirections (>file, 2>/dev/null, etc.), shell wrappers (command, builtin, exec)
195
260
  const redirections = /(?:(?:[0-9]*>[>&]?|<)\s*\S+\s+)*/.source;
196
261
  const pathPrefix = /(?:\/\S+\/)*/.source;
197
- const wrappers = /(?:(?:command|builtin|exec)\s+)*/.source;
262
+ // Wrappers/prefixes that may precede the interpreter name at a command position:
263
+ // command/builtin/exec shell builtins, and `uv run` (the test harness rewrites
264
+ // `python3` -> `uv run python3`, so `uv run python3 -c "$x"` must also be detected).
265
+ const wrappers = /(?:(?:command|builtin|exec)\s+|uv\s+run\s+)*/.source;
198
266
  const execPrefix = cmdPos + redirections + wrappers;
267
+ // Exec-primitive cores (shell/interpreter with a code-carrying flag or stdin/procsub).
268
+ const shellExec = /(?:ba|c|da|[akz]|fi|tc)?sh(?:\s+-c\b|\s+<<<|\s+<[^<])/.source;
269
+ const interpExec = /(?:python[23]?|perl|ruby|node|deno|bun)(?:\s+-[ce]\b|\s+<\()/.source;
270
+ // (a)/(b) INLINE: curl substitution survives safe-assignment stripping AND an exec
271
+ // primitive appears at command position — the curl output feeds the primitive.
199
272
  // Match shells with -c flag, stdin (<<<, <), or process substitution <(...)
200
273
  // Match interpreters with -c/-e flag or process substitution <(...)
201
- if (new RegExp(execPrefix + /eval(?:\s|$)/.source).test(cmdForExecCheck) ||
202
- new RegExp(execPrefix + pathPrefix + /(?:ba|c|da|[akz]|fi|tc)?sh(?:\s+-c\b|\s+<<<|\s+<[^<])/.source).test(cmdForExecCheck) ||
203
- new RegExp(execPrefix + pathPrefix + /(?:python[23]?|perl|ruby|node|deno|bun)(?:\s+-[ce]\b|\s+<\()/.source).test(cmdForExecCheck)) {
274
+ if (hasCurlSub && (
275
+ new RegExp(execPrefix + /eval(?:\s|$)/.source).test(cmdForExecCheck) ||
276
+ new RegExp(execPrefix + pathPrefix + shellExec).test(cmdForExecCheck) ||
277
+ new RegExp(execPrefix + pathPrefix + interpExec).test(cmdForExecCheck))) {
278
+ enfLog.debug("remote_exec_block", "inline-sub");
204
279
  return { block: true, reason: remoteExecReason };
205
280
  }
281
+ // (c) CONSERVATIVE VARIABLE-FLOW (SECURITY HARDENING): the previous logic only
282
+ // tracked the DIRECT curl-assigned variable name and was bypassable via aliasing
283
+ // (`x=$(curl); y=$x; bash -c "$y"`), quoted aliasing (`y="$x"; eval "$y"`), and
284
+ // indirect expansion (`bash -c "${!x}"`). We cannot statically track how curl output
285
+ // flows through arbitrary variable aliases/indirection, so we take the conservative
286
+ // direction: once curl output has been CAPTURED into the shell (curlVars.size > 0),
287
+ // ANY exec primitive whose command/argument region references ANY shell variable
288
+ // (contains a `$` — `$x`, `${x}`, `${!x}`, `"$x"`, ...) is assumed to potentially
289
+ // carry the curl output and is BLOCKED. Only an exec whose argument region contains
290
+ // NO `$` at all (e.g. `python3 -c "import json"`) is allowed to pass this rule.
291
+ if (curlVars.size > 0) {
292
+ // Argument/target region after the primitive, within the same statement (stops at
293
+ // ; & | newline), that contains at least one `$` variable reference.
294
+ const dollarArg = /[^\n;&|]*\$/.source;
295
+ if (new RegExp(execPrefix + /eval\b/.source + dollarArg).test(cmdForExecCheck) ||
296
+ new RegExp(execPrefix + pathPrefix + shellExec + dollarArg).test(cmdForExecCheck) ||
297
+ new RegExp(execPrefix + pathPrefix + interpExec + dollarArg).test(cmdForExecCheck)) {
298
+ enfLog.debug("remote_exec_block", "var-flow");
299
+ return { block: true, reason: remoteExecReason };
300
+ }
301
+ // (d) UNTRACKABLE INPUT: when a curl capture exists, a shell/interpreter that reads
302
+ // its program from stdin / a file / process-substitution / here-string is blocked
303
+ // regardless of variable reference — the curl output could have been redirected to a
304
+ // file that is then executed, which cannot be tracked statically. This is the
305
+ // conservative direction: only the inline `-c`/`-e` form (which needs an explicit
306
+ // `$` reference to consume curl output, handled by (c)) is allowed to pass through.
307
+ const shellStdin = /(?:ba|c|da|[akz]|fi|tc)?sh(?:\s+<<<|\s+<[^<]|\s+<\()/.source;
308
+ const interpStdin = /(?:python[23]?|perl|ruby|node|deno|bun)\s+<\(/.source;
309
+ if (new RegExp(execPrefix + pathPrefix + shellStdin).test(cmdForExecCheck) ||
310
+ new RegExp(execPrefix + pathPrefix + interpStdin).test(cmdForExecCheck)) {
311
+ enfLog.debug("remote_exec_block", "untrackable");
312
+ return { block: true, reason: remoteExecReason };
313
+ }
314
+ }
206
315
  }
207
316
  // Block $(curl ...) and `curl ...` UNLESS every occurrence is a safe shell variable assignment.
208
317
  // Safe: VAR=$(curl ...), export VAR=$(curl ...) — only when followed by ; && || or end-of-string
@@ -211,6 +320,7 @@ export function checkRemoteExecBlock(cmdLower: string): EnforcementResult {
211
320
  // Block env VAR=$(curl ...) cmd — env runs a command with the var, so curl output could influence execution
212
321
  // Note: [a-z_] without /i is fine — cmdLower (the parameter) is already lowercased
213
322
  if (/\benv\s+.*[a-z_]\w*=(?:\$\(|`).*\b(curl|wget)\b/.test(cmdForExecCheck)) {
323
+ enfLog.debug("remote_exec_block", "env-var-sub");
214
324
  return { block: true, reason: remoteExecReason };
215
325
  }
216
326
  // Strip safe assignment patterns at statement boundaries only.
@@ -225,6 +335,7 @@ export function checkRemoteExecBlock(cmdLower: string): EnforcementResult {
225
335
  const safeAssignment = /(?:^|(?<=[;&|\n({])\s*)(?:export\s+|declare\s+|local\s+|readonly\s+|typeset\s+)?[a-z_]\w*=(?:\$\((?:"[^"]*"|'[^']*'|(?!\$\()(?!`)[^)])*\)|`(?:(?!\$\()[^`])*`)(?=\s*(?:$|[;&|#\n)}]))/gi;
226
336
  const stripped = cmdForExecCheck.replace(safeAssignment, " ");
227
337
  if (/\$\(\s*\b(curl|wget)\b/.test(stripped) || /`\s*\b(curl|wget)\b/.test(stripped)) {
338
+ enfLog.debug("remote_exec_block", "bare-sub");
228
339
  return { block: true, reason: remoteExecReason };
229
340
  }
230
341
  }
@@ -468,6 +579,169 @@ export function isRmInProjectTmp(stmt: string, cwd: string): boolean {
468
579
  return true;
469
580
  }
470
581
 
582
+ /**
583
+ * Detect a REAL `git <sub>` invocation (sub = "commit" or "push") at a shell command
584
+ * position — NOT a substring inside a heredoc body, string argument, or file path.
585
+ *
586
+ * BUG #3 FIX: `hasGitSub` matches the literal text `git ... commit` ANYWHERE, so
587
+ * writing a file whose CONTENT mentions "git commit" (heredoc body), an echo/string
588
+ * arg like `echo 'run git commit later'`, or a path like `node "/tmp/git commit x.mjs"`
589
+ * wrongly triggered the commit/push guard. This helper narrows detection to genuine
590
+ * invocations by (1) stripping heredoc bodies and (2) requiring `git` at a command
591
+ * boundary (start, or after ; && || | & newline ( { or control-flow keywords).
592
+ *
593
+ * Security note: we DO NOT attempt full shell quote parsing. We block conservatively
594
+ * — any `git <flags> <sub>` at a command boundary in the (heredoc-stripped) command
595
+ * still blocks. This narrows false-positives without opening a bypass: a real
596
+ * `git commit`/`git push` cannot avoid appearing at a command boundary.
597
+ */
598
+ export function isRealGitCommitOrPush(command: string): boolean {
599
+ // Strip heredoc bodies so file CONTENT that mentions "git commit" does not count.
600
+ const stripped = stripHeredocBodies(command);
601
+ // Require git at a command position: start-of-string, or after a statement separator
602
+ // / pipe / background / newline / subshell-open / brace-group / control-flow keyword.
603
+ const boundary = /(?:^|[;&|\n(){]|&&|\|\||\bthen\b|\bdo\b|\belse\b|\belif\b)\s*/.source;
604
+ // PREFIX BYPASS FIX: real git invocations can be preceded by allowed prefixes that the
605
+ // old boundary-only check missed — `sudo git commit`, `env GIT_DIR=x git commit`,
606
+ // `GIT_DIR=x git commit` (bare VAR=value assignment prefix), `command git commit`,
607
+ // `builtin`/`exec` wrappers. Allow an optional sequence of these before git:
608
+ // - sudo (with flags), env (with flags)
609
+ // - one-or-more VAR=value assignments
610
+ // - command / builtin / exec wrappers
611
+ //
612
+ // SUDO/ENV ARG BYPASS FIX (finding #3): real sudo/env flags TAKE A FOLLOWING
613
+ // ARGUMENT (`sudo -u root git commit`, `sudo -g grp git commit`, `env -u HOME git
614
+ // commit`), and env accepts VAR=value operands (`env FOO=bar git commit`). The old
615
+ // `(?:\s+-\S+)*` only consumed bare flags, so the arg token stopped the prefix early
616
+ // and these BYPASSED the guard. We now consume, after `sudo`/`env`, a sequence of:
617
+ // - `--long ARG` long option taking an argument (`--user root`)
618
+ // - `-x ARG` short option taking an argument (`-u root`, `-g grp`)
619
+ // - `-\S+` bare flag (`-n`, `--preserve-env`, combined `-xyz`)
620
+ // - `VAR=value` env operand (`FOO=bar`)
621
+ // A `(?!git\b)` guard on the arg-consuming forms prevents swallowing the `git` token
622
+ // itself; alternatives are ordered specific-first and backtracking resolves the rest.
623
+ const sudoEnvArg = /(?:sudo|env)(?:\s+(?:--\S+\s+(?!git\b)\S+|-[a-zA-Z]\s+(?!git\b)\S+|-\S+|[A-Za-z_]\w*=(?:"[^"]*"|'[^']*'|\S*)))*\s+/.source;
624
+ const assignArg = /[A-Za-z_]\w*=(?:"[^"]*"|'[^']*'|\S*)\s+/.source;
625
+ const wrapArg = /(?:command|builtin|exec)\s+/.source;
626
+ const prefix = `(?:${sudoEnvArg}|${assignArg}|${wrapArg})*`;
627
+ // Allow an optional path prefix on git itself: `/usr/bin/git`, `/bin/git`, etc. The
628
+ // executable must END in `/git` so that a path ARGUMENT to another program (e.g.
629
+ // `node "/tmp/git commit test.mjs"`) is NOT matched — there git is not the invoked
630
+ // command (first token is `node`, "git commit" lives inside a quoted arg).
631
+ const gitExe = /(?:\S*\/)?git\b/.source;
632
+ // Then allow git's own flags (git -c k=v, git -C dir, --no-pager, etc.) before the sub.
633
+ const gitFlags = /(?:\s+(?:-[a-zA-Z]\s+\S+|-\S+))*/.source;
634
+ for (const sub of ["commit", "push"]) {
635
+ if (new RegExp(boundary + prefix + gitExe + gitFlags + `\\s+${sub}\\b`).test(stripped)) {
636
+ enfLog.debug("git_invocation_detected", sub);
637
+ return true;
638
+ }
639
+ }
640
+ return false;
641
+ }
642
+
643
+ /**
644
+ * Inject `--signoff` into the REAL git-commit invocation only (DCO enforcement).
645
+ *
646
+ * BUG THIS FIXES: a naive `command.replace(/\bgit\b...commit\b/, ...)` injects
647
+ * `--signoff` into the FIRST `git ... commit` substring anywhere in the command —
648
+ * including a `git commit` mention INSIDE an echo/printf message body before a pipe
649
+ * (e.g. `echo -e "...sudo -u root git commit..." | git commit -F -`), corrupting the
650
+ * commit MESSAGE instead of the real invocation. This mirrors the trailer-injection
651
+ * scoping in handleCommitTrailer.
652
+ *
653
+ * Behavior:
654
+ * - Pattern A (echo/printf "..." | git commit -F -): the real invocation is AFTER
655
+ * the last pipe. Inject into the gitPart only; never touch the echo/message body.
656
+ * - Pattern B (bare `git commit ...`, `git commit -m/-F ...` with no commit pipe):
657
+ * inject `--signoff` right after the `commit` token of the real invocation, using
658
+ * the same command-position/prefix boundary logic as isRealGitCommitOrPush so a
659
+ * `git commit` substring inside a quoted arg is never matched.
660
+ * - If the real invocation already has `--signoff` or `-s` -> return unchanged.
661
+ * - If there is no real git-commit invocation -> return unchanged.
662
+ */
663
+ export function injectSignoff(command: string): string {
664
+ const SIGNOFF_RE = /(?:^|\s)--signoff(?:\s|$)/;
665
+ const SHORT_S_RE = /(?:^|\s)-s(?:\s|$)/;
666
+ // Reuse the exact command-position/prefix construction from isRealGitCommitOrPush,
667
+ // capturing everything up to and including the `commit` token so we can insert
668
+ // ` --signoff` immediately after it via a NON-global replace (first real match only).
669
+ const boundary = /(?:^|[;&|\n(){]|&&|\|\||\bthen\b|\bdo\b|\belse\b|\belif\b)\s*/.source;
670
+ const sudoEnvArg = /(?:sudo|env)(?:\s+(?:--\S+\s+(?!git\b)\S+|-[a-zA-Z]\s+(?!git\b)\S+|-\S+|[A-Za-z_]\w*=(?:"[^"]*"|'[^']*'|\S*)))*\s+/.source;
671
+ const assignArg = /[A-Za-z_]\w*=(?:"[^"]*"|'[^']*'|\S*)\s+/.source;
672
+ const wrapArg = /(?:command|builtin|exec)\s+/.source;
673
+ const prefix = `(?:${sudoEnvArg}|${assignArg}|${wrapArg})*`;
674
+ const gitExe = /(?:\S*\/)?git\b/.source;
675
+ const gitFlags = /(?:\s+(?:-[a-zA-Z]\s+\S+|-\S+))*/.source;
676
+ const commitRe = new RegExp(`(${boundary}${prefix}${gitExe}${gitFlags}\\s+commit\\b)`);
677
+
678
+ // Pattern A: echo/printf "..." | git commit -F - (real invocation after last pipe)
679
+ const pipeIdx = command.lastIndexOf("|");
680
+ if (pipeIdx !== -1 && /git\s+commit\s+.*-F\s*-/.test(command.slice(pipeIdx))) {
681
+ const echoPart = command.slice(0, pipeIdx);
682
+ const gitPart = command.slice(pipeIdx);
683
+ if (SIGNOFF_RE.test(gitPart) || SHORT_S_RE.test(gitPart)) return command;
684
+ if (!commitRe.test(gitPart)) return command;
685
+ enfLog.debug("injectSignoff", "pattern A (piped git commit -F -)");
686
+ return echoPart + gitPart.replace(commitRe, "$1 --signoff");
687
+ }
688
+
689
+ // Pattern B: bare / inline git commit (no message pipe)
690
+ const m = commitRe.exec(command);
691
+ if (!m) return command;
692
+ // Scope the "already signed" check to the MATCHED real invocation ONLY, not the
693
+ // whole command. The invocation region runs from the start of the match to the
694
+ // next UNQUOTED statement boundary (; && || | & newline) or end-of-string.
695
+ const regionStart = m.index;
696
+ let regionEnd = command.length;
697
+ {
698
+ // Escape-aware quote-state scan. In POSIX sh, backslash escapes the next char
699
+ // ONLY inside double quotes (and unquoted); inside single quotes a backslash is
700
+ // literal and only a `'` ends the string. A `\"` inside a double-quoted string is
701
+ // an escaped quote (stays inside), and `\\` is an escaped backslash (does not
702
+ // escape the following quote). Getting this right prevents a `\"` from wrongly
703
+ // toggling quote state — which would let an inner `;`/`&`/`|` be seen as a real
704
+ // statement boundary (or hide a real one) and miscompute the invocation region.
705
+ let inSingle = false;
706
+ let inDouble = false;
707
+ for (let i = regionStart; i < command.length; i++) {
708
+ const c = command[i];
709
+ if (inSingle) {
710
+ // Single quotes: no backslash processing; only `'` terminates.
711
+ if (c === "'") inSingle = false;
712
+ continue;
713
+ }
714
+ if (inDouble) {
715
+ // Double quotes: `\` escapes the next char (e.g. \" or \\) — skip it.
716
+ if (c === "\\") { i++; continue; }
717
+ if (c === '"') inDouble = false;
718
+ continue;
719
+ }
720
+ // Unquoted: `\` escapes the next char so an escaped quote does not open a string.
721
+ if (c === "\\") { i++; continue; }
722
+ if (c === "'") { inSingle = true; continue; }
723
+ if (c === '"') { inDouble = true; continue; }
724
+ if (c === "\n" || c === ";" || c === "&" || c === "|") { regionEnd = i; break; }
725
+ }
726
+ }
727
+ // Strip quoted substrings from the region so a `-s`/`--signoff` INSIDE the
728
+ // -m/-F message value (e.g. `-m "fix -s bug"`) is NOT treated as already-signed.
729
+ // Escape-aware: a `\"` inside a double-quoted span does NOT terminate it, so an
730
+ // inner `-s`/`--signoff` fenced by escaped quotes (`-m "a \" -s \" b"`) is still
731
+ // stripped away — otherwise it would leak out as an unquoted flag and suppress
732
+ // injection on an unsigned real commit (DCO bypass). Double-quoted spans are
733
+ // stripped FIRST so the single-quote regex cannot misfire on an apostrophe that
734
+ // lives literally inside a double-quoted string. Single quotes do NOT process
735
+ // backslashes in POSIX sh, so their regex stays escape-agnostic.
736
+ const region = command
737
+ .slice(regionStart, regionEnd)
738
+ .replace(/"(?:\\.|[^"\\])*"/g, '""')
739
+ .replace(/'[^']*'/g, "''");
740
+ if (SIGNOFF_RE.test(region) || SHORT_S_RE.test(region)) return command;
741
+ enfLog.debug("injectSignoff", "pattern B (inline git commit)");
742
+ return command.replace(commitRe, "$1 --signoff");
743
+ }
744
+
471
745
  /** Detect git add --force / -f (including combined short options like -fn).
472
746
  * Respects -- end-of-options marker and shell separators (&&, ;, |, ||). */
473
747
  export function hasGitAddForce(command: string): boolean {
@@ -512,6 +786,140 @@ export function stripHeredocBodies(cmd: string): string {
512
786
  .replace(/<<(\s*)['"]?(\w+)['"]?[^\n]*\n[\s\S]*?\n\2\s*(?=\n|$)/gm, "");
513
787
  }
514
788
 
789
+ /**
790
+ * True when `gh pr|issue create|comment|edit` is a real invocation (command
791
+ * boundary), not a mention inside a string/heredoc body.
792
+ */
793
+ export function isRealGhBodyCommand(command: string): boolean {
794
+ const stripped = stripHeredocBodies(command);
795
+ const boundary = /(?:^|[;&|\n(){]|&&|\|\||\bthen\b|\bdo\b|\belse\b|\belif\b)\s*/.source;
796
+ const ghExe = /(?:\S*\/)?gh\b/.source;
797
+ const re = new RegExp(
798
+ boundary + ghExe + String.raw`\s+(?:pr|issue)\s+(?:create|comment|edit)\b`,
799
+ );
800
+ const result = re.test(stripped);
801
+ enfLog.debug("isRealGhBodyCommand", result);
802
+ return result;
803
+ }
804
+
805
+ function commentSignatureFooter(signature: string): string {
806
+ return `\n\n---\n*${signature}*`;
807
+ }
808
+
809
+ function bodyAlreadySigned(text: string): boolean {
810
+ return /\*Assisted-by:/.test(text) || /(?:^|\n|\\n)Assisted-by:\s/.test(text);
811
+ }
812
+
813
+ /** Index of the matching closer for a quote at `openIdx`, honoring POSIX escapes. */
814
+ function matchingQuoteIndex(s: string, openIdx: number): number | null {
815
+ const q = s[openIdx];
816
+ if (q !== '"' && q !== "'") return null;
817
+ if (q === "'") {
818
+ const close = s.indexOf("'", openIdx + 1);
819
+ enfLog.debug("matchingQuoteIndex single", close != null && close >= 0);
820
+ return close < 0 ? null : close;
821
+ }
822
+ for (let i = openIdx + 1; i < s.length; i++) {
823
+ if (s[i] === "\\") {
824
+ i++;
825
+ continue;
826
+ }
827
+ if (s[i] === '"') {
828
+ enfLog.debug("matchingQuoteIndex double", i);
829
+ return i;
830
+ }
831
+ }
832
+ enfLog.debug("matchingQuoteIndex unclosed double");
833
+ return null;
834
+ }
835
+
836
+ /**
837
+ * Last quoted `--body` / `--body=` span in `command`. Conservative: unquoted
838
+ * or unclosed values are skipped (caller no-ops).
839
+ */
840
+ function findLastQuotedBodySpan(command: string): { open: number; close: number } | null {
841
+ const flagRe = /--body(?:=|\s+)/g;
842
+ let last: { open: number; close: number } | null = null;
843
+ let fm: RegExpExecArray | null;
844
+ while ((fm = flagRe.exec(command)) !== null) {
845
+ const after = fm.index + fm[0].length;
846
+ const q = command[after];
847
+ if (q !== '"' && q !== "'") {
848
+ enfLog.debug("findLastQuotedBodySpan skip unquoted --body");
849
+ continue;
850
+ }
851
+ const close = matchingQuoteIndex(command, after);
852
+ if (close == null) {
853
+ enfLog.warn("findLastQuotedBodySpan unclosed --body quote");
854
+ continue;
855
+ }
856
+ last = { open: after, close };
857
+ }
858
+ enfLog.debug("findLastQuotedBodySpan", last ? "found" : "none");
859
+ return last;
860
+ }
861
+
862
+ function lastHeredocInSpan(
863
+ command: string,
864
+ spanStart: number,
865
+ spanEnd: number,
866
+ ): RegExpExecArray | null {
867
+ const heredocRe = /<<(?:-)?\s*(['"]?)(\w+)\1[^\n]*\n([\s\S]*?)\n([ \t]*)\2\s*(?=\n|$)/g;
868
+ let last: RegExpExecArray | null = null;
869
+ let m: RegExpExecArray | null;
870
+ while ((m = heredocRe.exec(command)) !== null) {
871
+ if (m.index > spanStart && m.index < spanEnd) last = m;
872
+ }
873
+ enfLog.debug("lastHeredocInSpan", last ? last[2] : "none");
874
+ return last;
875
+ }
876
+
877
+ /**
878
+ * Append the comment_signature footer to `gh pr|issue create|comment|edit`
879
+ * `--body` / heredoc payloads. Idempotent. Empty signature is a no-op.
880
+ * Only mutates a heredoc that sits inside the `--body` quoted span.
881
+ */
882
+ export function injectGhBodySignature(command: string, signature: string): string {
883
+ if (!signature) {
884
+ enfLog.debug("injectGhBodySignature skip empty signature");
885
+ return command;
886
+ }
887
+ if (!isRealGhBodyCommand(command)) {
888
+ enfLog.debug("injectGhBodySignature skip not a gh body command");
889
+ return command;
890
+ }
891
+ if (bodyAlreadySigned(command)) {
892
+ enfLog.debug("injectGhBodySignature skip already present");
893
+ return command;
894
+ }
895
+ const footer = commentSignatureFooter(signature);
896
+ const span = findLastQuotedBodySpan(command);
897
+ if (!span) {
898
+ enfLog.debug("injectGhBodySignature no quoted --body found");
899
+ return command;
900
+ }
901
+
902
+ const last = lastHeredocInSpan(command, span.open, span.close);
903
+ if (last) {
904
+ const delim = last[2];
905
+ const indent = last[4] ?? "";
906
+ const closeToken = `\n${indent}${delim}`;
907
+ const closeIdx = last.index + last[0].lastIndexOf(closeToken);
908
+ if (closeIdx >= last.index) {
909
+ enfLog.debug("injectGhBodySignature heredoc in --body", delim);
910
+ return command.slice(0, closeIdx) + footer + command.slice(closeIdx);
911
+ }
912
+ }
913
+
914
+ const payload = command.slice(span.open + 1, span.close);
915
+ if (payload.startsWith("$(")) {
916
+ enfLog.warn("injectGhBodySignature --body command subst without heredoc match");
917
+ return command;
918
+ }
919
+ enfLog.debug("injectGhBodySignature --body quoted");
920
+ return command.slice(0, span.close) + footer + command.slice(span.close);
921
+ }
922
+
515
923
  /**
516
924
  * Detect common test runner commands — require command-start position
517
925
  * (after &&, |, ;, or line start) to avoid false positives from install/grep/cat commands.
@@ -528,3 +936,37 @@ export function isTestRunnerCommand(command: string): boolean {
528
936
  || /(?:^|[;&|]\s*)npm\s+test\b/.test(command)
529
937
  || /(?:^|[;&|]\s*)npx\s+tsx\s+--test\b/.test(command);
530
938
  }
939
+
940
+ /** True for release bump branches: chore/bump-version-<digit>... */
941
+ export function isBumpVersionBranch(branch: string | null): boolean {
942
+ return !!branch && /^chore\/bump-version-\d/.test(branch);
943
+ }
944
+
945
+ /** Branch cache per cwd — avoids repeated git calls on hot edit/write path. */
946
+ const branchCache = new Map<string, { branch: string | null; at: number }>();
947
+ const BRANCH_CACHE_TTL_MS = 5_000;
948
+
949
+ export function getCachedBranch(cwd: string): string | null {
950
+ const now = Date.now();
951
+ const cached = branchCache.get(cwd);
952
+ if (cached && now - cached.at < BRANCH_CACHE_TTL_MS) return cached.branch;
953
+ const branch = getCurrentBranch(cwd);
954
+ // Only cache non-bump-version branches — bump branches must always be fresh
955
+ // to avoid stale results after switching away from a release branch.
956
+ if (!isBumpVersionBranch(branch)) {
957
+ branchCache.set(cwd, { branch, at: now });
958
+ } else {
959
+ branchCache.delete(cwd);
960
+ }
961
+ return branch;
962
+ }
963
+
964
+ /** Clear branch cache (tests). */
965
+ export function clearBranchCache(): void {
966
+ branchCache.clear();
967
+ }
968
+
969
+ /** Seed branch cache for testing — inject a value with custom timestamp. */
970
+ export function seedBranchCacheForTests(cwd: string, branch: string | null, at?: number): void {
971
+ branchCache.set(cwd, { branch, at: at ?? Date.now() });
972
+ }
@@ -16,6 +16,7 @@ import {
16
16
  type EnforcementAction,
17
17
  } from "./memory-scoring.js";
18
18
  import { readAllTopicEntries } from "./memory-tree.js";
19
+ import { getSetting } from "./project-settings.js";
19
20
 
20
21
  // ── Types ──────────────────────────────────────────────────────────────────
21
22
 
@@ -52,7 +53,7 @@ export interface ActionResult {
52
53
 
53
54
  /**
54
55
  * Load all active memory entries that have enforcement fields.
55
- * Reads from the existing memory-scores.json — no separate storage.
56
+ * Reads from the existing memory-scores.jsonl — no separate storage.
56
57
  */
57
58
  export function loadEnforcedEntries(cwd: string): EnforcedEntry[] {
58
59
  // Load ALL entries with trigger+action, not just active/provisional.
@@ -287,9 +288,9 @@ export function executeAction(
287
288
  command: string,
288
289
  cwd: string,
289
290
  ): ActionResult {
290
- // Optional allowlist — if PI_ENFORCEMENT_ALLOWED_COMMANDS is set,
291
+ // Optional allowlist — if enforcement_allowed_commands setting is set,
291
292
  // only permit exact-match commands (colon-separated)
292
- const allowedEnv = process.env.PI_ENFORCEMENT_ALLOWED_COMMANDS;
293
+ const allowedEnv = getSetting(cwd, "enforcement_allowed_commands") as string;
293
294
  if (allowedEnv) {
294
295
  const allowlist = allowedEnv.split(":").map(s => s.trim()).filter(Boolean);
295
296
  // Exact match only — no prefix matching to prevent shell chaining bypass
@@ -297,7 +298,7 @@ export function executeAction(
297
298
  const commandTrimmed = command.trim();
298
299
  const permitted = allowlist.some(allowed => commandTrimmed === allowed);
299
300
  if (!permitted) {
300
- return { output: `Blocked: command not in PI_ENFORCEMENT_ALLOWED_COMMANDS allowlist`, success: false };
301
+ return { output: `Blocked: command not in enforcement_allowed_commands allowlist`, success: false };
301
302
  }
302
303
  }
303
304