pi-orchestrator-config 4.13.0 → 4.15.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
@@ -600,6 +600,28 @@ files organized by category (lessons, preferences, patterns, decisions, completi
600
600
  - **Pinned** — user-requested memories (via `/remember`), never auto-removed
601
601
  - **Learned** — auto-extracted by dreaming, can be reorganized/removed
602
602
 
603
+ **Code-enforced memories and trigger warnings** — a memory with a `trigger`
604
+ (`bash_contains`, `bash_regex`, `tool_name`, `file_modified`) and an `action`
605
+ (`block`, `warn`, `run_after <command>`) is enforced by runtime hooks; the LLM
606
+ cannot ignore it. When you store one, the write response may carry a warning:
607
+
608
+ - `tool_name <shell-command>` (curl, npm, python, …) — names a shell command,
609
+ not a pi tool: commands run inside `bash`, so the trigger never fires. Use a
610
+ `bash_contains` pattern for the specific command instead.
611
+ - `tool_name bash` — fires on EVERY bash call; usually over-broad. Prefer a
612
+ `bash_contains` pattern targeting the specific command.
613
+ - A trigger without an action — never fires at all; add `block`, `warn`, or
614
+ `run_after <command>` for enforcement to load it.
615
+
616
+ **Conflict gate** — when a new memory contradicts an existing one, the write is
617
+ refused with an ask-first message instead of stored silently. Complementary
618
+ rules coexist: different targets (`deploy production` vs `deploy staging`),
619
+ restatements, coordinated or multi-sentence rules, and quoted examples are not
620
+ conflicts. A genuine same-action polarity flip (`never X` vs `always X`, in any
621
+ wording) rejects and asks which rule is correct; a confirmed rule change
622
+ re-supplies the write with `overrideConflicts: true` and supersedes the old
623
+ entry. Pinned entries are never auto-superseded — the user edits them directly.
624
+
603
625
  The `.pi/memory/` directory is auto-added to the global gitignore in the container
604
626
  (along with `.pi/`, `.worktrees/`, `.cursor/agents/`, `.claude/agents/`, and
605
627
  `.gemini/agents/`). This repo's `.gitignore` also ignores those three `*/agents/`
@@ -1905,3 +1905,13 @@ export function clearBranchCache(): void {
1905
1905
  export function seedBranchCacheForTests(cwd: string, branch: string | null, at?: number): void {
1906
1906
  branchCache.set(cwd, { branch, at: at ?? Date.now() });
1907
1907
  }
1908
+
1909
+ /** Diagnostic for a FAILED edit/write skipping file_modified triggers
1910
+ * (#940, Qodo 2addbfc4): nothing was modified, so file-pattern rules must
1911
+ * not fire. Logs at debug level itself (Qodo ea636782 — logging
1912
+ * requirements apply to helpers too) and returns the message for tests. */
1913
+ export function failedFileEditSkipMessage(toolName: string, path: string): string {
1914
+ const msg = `enforcement: failed ${toolName} (path=${path}) — skipping file_modified triggers, nothing was modified`;
1915
+ enfLog.debug(msg);
1916
+ return msg;
1917
+ }
@@ -220,11 +220,17 @@ export function matchBashCommand(
220
220
 
221
221
  /**
222
222
  * Find all enforced entries whose triggers match a tool call.
223
+ *
224
+ * `isError` marks a FAILED tool call: `file_modified` triggers must not
225
+ * match then (nothing was modified — e.g. an edit whose oldText was not
226
+ * found), while `tool_name` and bash matching stay live (a failed bash
227
+ * command still executed; tool_name matches the invocation itself).
223
228
  */
224
229
  export function matchToolCall(
225
230
  entries: EnforcedEntry[],
226
231
  toolName: string,
227
232
  input: Record<string, any>,
233
+ isError = false,
228
234
  ): TriggerMatch[] {
229
235
  const matches: TriggerMatch[] = [];
230
236
  for (const rule of entries) {
@@ -244,8 +250,9 @@ export function matchToolCall(
244
250
  }
245
251
  }
246
252
 
247
- // Check file_modified for write/edit tools
248
- if ((toolName === "write" || toolName === "edit") && input?.path) {
253
+ // Check file_modified for write/edit tools — only when the call
254
+ // actually (modified) the file, not when it failed before writing.
255
+ if (!isError && (toolName === "write" || toolName === "edit") && input?.path) {
249
256
  const fileHit = matchFileTrigger(rule.trigger, input.path);
250
257
  if (fileHit) {
251
258
  matches.push({ rule, matched: fileHit });
@@ -61,6 +61,7 @@ import {
61
61
  isTestRunnerCommand,
62
62
  isBumpVersionBranch,
63
63
  getCachedBranch,
64
+ failedFileEditSkipMessage,
64
65
  } from "./enforcement-helpers.js";
65
66
 
66
67
  const log = createLogger("enforcement");
@@ -741,7 +742,13 @@ export function registerEnforcement(pi: ExtensionAPI, inContainer?: boolean): vo
741
742
 
742
743
 
743
744
 
744
- let matches = matchToolCall(entries, toolName, input);
745
+ // Failed edit/write calls skip file_modified matching (#940) — log the
746
+ // decision so silent trigger skips are diagnosable (Qodo 2addbfc4).
747
+ const isToolCallError = (event as any).isError === true;
748
+ if (isToolCallError && (toolName === "edit" || toolName === "write") && input?.path) {
749
+ failedFileEditSkipMessage(toolName, input.path);
750
+ }
751
+ let matches = matchToolCall(entries, toolName, input, isToolCallError);
745
752
 
746
753
  // For subagent results, extract actual bash commands from the subagent's
747
754
  // tool call messages (structured data only — no prose text fallback).
@@ -756,6 +763,26 @@ export function registerEnforcement(pi: ExtensionAPI, inContainer?: boolean): vo
756
763
  const bashCommands: string[] = [];
757
764
  for (const r of results) {
758
765
  const msgs = Array.isArray(r?.messages) ? r.messages : [];
766
+ // Error state per tool call (#940, Qodo 1f528064): a subagent's
767
+ // FAILED edit/write modified nothing, so its file_modified triggers
768
+ // must be skipped exactly like a failed top-level call. The result
769
+ // messages carry role "toolResult" entries with toolCallId/isError.
770
+ // A call with NO result at all (Qodo 2d02b5d5) — the child exited
771
+ // after emitting the call but before its result — has an UNKNOWN
772
+ // outcome, so it must not count as a successful modification
773
+ // either: file_modified warnings fire only for calls that
774
+ // explicitly succeeded.
775
+ const erroredCalls = new Set<string>();
776
+ const resolvedCalls = new Set<string>();
777
+ for (const msg of msgs) {
778
+ if (msg?.role !== "toolResult") continue;
779
+ if (typeof (msg as any).toolCallId === "string") {
780
+ resolvedCalls.add((msg as any).toolCallId);
781
+ }
782
+ if ((msg as any).isError === true && typeof (msg as any).toolCallId === "string") {
783
+ erroredCalls.add((msg as any).toolCallId);
784
+ }
785
+ }
759
786
  for (const msg of msgs) {
760
787
  if (msg?.role !== "assistant") continue;
761
788
  const parts = Array.isArray(msg?.content) ? msg.content : [];
@@ -764,15 +791,29 @@ export function registerEnforcement(pi: ExtensionAPI, inContainer?: boolean): vo
764
791
  // Check both field naming conventions (name/arguments and toolName/args)
765
792
  const partToolName = part?.name || part?.toolName;
766
793
  const partToolArgs = part?.arguments || part?.args;
794
+ const partCallId = part?.id ?? part?.toolCallId;
795
+ const partIsError = partCallId !== undefined && erroredCalls.has(partCallId);
796
+ const partUnresolved = partCallId !== undefined && !resolvedCalls.has(partCallId);
767
797
  if (partToolName === "bash" && partToolArgs?.command) {
768
798
  bashCommands.push(partToolArgs.command);
769
799
  }
770
800
  // Also check non-bash tool calls against tool_name/file_modified triggers
771
801
  if (partToolName && partToolName !== "bash") {
802
+ // Failed child edit/write calls skip file_modified matching
803
+ // like top-level ones (#940) — leave the same diagnostic
804
+ // naming the tool and path (Qodo b00dc05f).
805
+ if (
806
+ partIsError &&
807
+ (partToolName === "edit" || partToolName === "write") &&
808
+ partToolArgs?.path
809
+ ) {
810
+ failedFileEditSkipMessage(partToolName, partToolArgs.path);
811
+ }
772
812
  const toolMatches = matchToolCall(
773
813
  nonBlockEntries,
774
814
  partToolName,
775
815
  partToolArgs || {},
816
+ partIsError || partUnresolved,
776
817
  );
777
818
  for (const m of toolMatches) {
778
819
  if (!seen.has(m.rule.hash)) {
@@ -657,9 +657,14 @@ const PREFERENCE_PATTERNS: RegExp[] = [
657
657
  ];
658
658
 
659
659
  /** Sentence starts that mark a question, not a statement. */
660
- /** One-off permission grant phrasing — session instruction, not a durable preference. */
660
+ /** One-off permission grant phrasing — session instruction, not a durable
661
+ * preference. Negation-blind "you may" would reject prohibitions like
662
+ * "You may not merge PRs without review" (live battery, #936), so a
663
+ * grant followed by not/never/'t — or a withdrawal like "no longer" —
664
+ * does not count (Qodo 77fc2333: "You can no longer force-push" is a
665
+ * standing rule, not a grant). */
661
666
  const GRANT_PHRASE_RE: RegExp[] = [
662
- /\byou (can|may)\b/i,
667
+ /\byou (?:can|may)\b(?!\s+(?:not(?!\s+only\b)|never|no\s+longer)\b)(?!['’]t\b)/i,
663
668
  /\bgo ahead\b/i,
664
669
  /\bfeel free\b/i,
665
670
  /\bjust this once\b/i,
@@ -671,7 +676,15 @@ const GRANT_PHRASE_RE: RegExp[] = [
671
676
 
672
677
  /** Detect one-off grant phrasing (shared by extractor and memory_add guardrail). */
673
678
  export function isGrantText(text: string): boolean {
674
- return GRANT_PHRASE_RE.some((re) => re.test(text));
679
+ // Classification is logged (Qodo 64954ee5): the negation-aware grant
680
+ // patterns decide whether a permission-looking sentence is a durable
681
+ // rule or a one-off grant, and silent decisions left runtime
682
+ // diagnostics blind when the classification changed behavior.
683
+ const matched = GRANT_PHRASE_RE.find((re) => re.test(text));
684
+ if (matched) {
685
+ memLog.debug("memory-scoring: grant phrase detected", text.slice(0, 120), matched.source);
686
+ }
687
+ return matched !== undefined;
675
688
  }
676
689
 
677
690
  /**