pi-orchestrator-config 4.14.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 +22 -0
- package/extensions/orchestrator/enforcement-helpers.ts +10 -0
- package/extensions/orchestrator/enforcement-rules.ts +9 -2
- package/extensions/orchestrator/enforcement.ts +42 -1
- package/extensions/orchestrator/memory-scoring.ts +16 -3
- package/extensions/orchestrator/memory-write.ts +1811 -26
- package/extensions/pidiff/pidiff-ui/package.json +1 -1
- package/package.json +1 -1
- package/rules/60-task-tracking.md +10 -0
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
/**
|