pi-advisor-flow 0.2.4 → 0.2.7

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/src/config.ts CHANGED
@@ -7,6 +7,11 @@ import {
7
7
  type ExtensionContext,
8
8
  getAgentDir,
9
9
  } from "@earendil-works/pi-coding-agent";
10
+ import {
11
+ GIT_CONTEXT_LEVELS,
12
+ type GitContextLevel,
13
+ isValidGitContextLevel,
14
+ } from "./git.js";
10
15
 
11
16
  export const FALLBACK_EXECUTOR = "aikeys/claude-sonnet-5";
12
17
  export const FALLBACK_ADVISOR = "aikeys/claude-fable-5";
@@ -14,6 +19,7 @@ export const DEFAULT_CONTEXT_MAX_CHARS = 15_000;
14
19
  export const MAX_CONTEXT_MAX_CHARS = Number.MAX_SAFE_INTEGER;
15
20
  export const DEFAULT_ADVISOR_TOOL_RESULT_MAX_LINES = DEFAULT_MAX_LINES;
16
21
  export const DEFAULT_ADVISOR_TOOL_RESULT_MAX_BYTES = DEFAULT_MAX_BYTES;
22
+ export const DEFAULT_ADVISOR_GIT_CONTEXT_MAX_CHARS = 20_000;
17
23
  export type AdvisorToolPolicy = "full" | "summary" | "exclude";
18
24
  export type AdvisorToolPolicies = Record<string, AdvisorToolPolicy>;
19
25
  export const ADVISOR_TOOL_POLICIES: AdvisorToolPolicy[] = [
@@ -53,7 +59,11 @@ export let advisorHerdrIntegrationRef = true;
53
59
  export let advisorToolResultMaxLinesRef = DEFAULT_ADVISOR_TOOL_RESULT_MAX_LINES;
54
60
  export let advisorToolResultMaxBytesRef = DEFAULT_ADVISOR_TOOL_RESULT_MAX_BYTES;
55
61
  export let advisorRedactSecretsRef = false;
62
+ export let advisorGitContextRef: GitContextLevel = "summary";
63
+ export let advisorGitContextMaxCharsRef = DEFAULT_ADVISOR_GIT_CONTEXT_MAX_CHARS;
56
64
  export let advisorToolPoliciesRef: AdvisorToolPolicies = {};
65
+ export let advisorOutcomeLoggingRef = false;
66
+ export let advisorUntrackedContentRef = false;
57
67
 
58
68
  export const setExecutorRef = (ref: string) => {
59
69
  executorRef = ref;
@@ -141,9 +151,21 @@ export const setAdvisorToolResultMaxBytesRef = (value: number) => {
141
151
  export const setAdvisorRedactSecretsRef = (enabled: boolean) => {
142
152
  advisorRedactSecretsRef = enabled;
143
153
  };
154
+ export const setAdvisorGitContextRef = (level: GitContextLevel) => {
155
+ advisorGitContextRef = level;
156
+ };
157
+ export const setAdvisorGitContextMaxCharsRef = (value: number) => {
158
+ advisorGitContextMaxCharsRef = value;
159
+ };
144
160
  export const setAdvisorToolPoliciesRef = (policies: AdvisorToolPolicies) => {
145
161
  advisorToolPoliciesRef = { ...policies };
146
162
  };
163
+ export const setAdvisorOutcomeLoggingRef = (enabled: boolean) => {
164
+ advisorOutcomeLoggingRef = enabled;
165
+ };
166
+ export const setAdvisorUntrackedContentRef = (enabled: boolean) => {
167
+ advisorUntrackedContentRef = enabled;
168
+ };
147
169
 
148
170
  /**
149
171
  * Returns the current live settings state. Use this at UI boundaries instead of
@@ -160,9 +182,12 @@ export const getAdvisorSettings = () => ({
160
182
  effort: advisorEffortRef,
161
183
  failureGate: advisorFailureGateRef,
162
184
  failureMode: advisorFailureModeRef,
185
+ gitContext: advisorGitContextRef,
186
+ gitContextMaxChars: advisorGitContextMaxCharsRef,
163
187
  herdrIntegration: advisorHerdrIntegrationRef,
164
188
  loopThreshold: advisorLoopThresholdRef,
165
189
  maxCallsPerSession: advisorMaxCallsPerSessionRef,
190
+ outcomeLogging: advisorOutcomeLoggingRef,
166
191
  planGate: advisorPlanGateRef,
167
192
  redactSecrets: advisorRedactSecretsRef,
168
193
  sessionSummary: advisorSessionSummaryRef,
@@ -170,6 +195,7 @@ export const getAdvisorSettings = () => ({
170
195
  toolPolicies: { ...advisorToolPoliciesRef },
171
196
  toolResultMaxBytes: advisorToolResultMaxBytesRef,
172
197
  toolResultMaxLines: advisorToolResultMaxLinesRef,
198
+ untrackedContent: advisorUntrackedContentRef,
173
199
  });
174
200
 
175
201
  export const splitRef = (ref: string): [string, string] => {
@@ -193,15 +219,19 @@ export interface AdvisorConfig {
193
219
  advisorCustomInvocation?: string;
194
220
  advisorEffort?: string;
195
221
  advisorFailureGate?: boolean;
222
+ advisorGitContext?: GitContextLevel;
223
+ advisorGitContextMaxChars?: number;
196
224
  advisorHerdrIntegration?: boolean;
197
225
  advisorLoopThreshold?: number;
198
226
  advisorMaxCallsPerSession?: number;
227
+ advisorOutcomeLogging?: boolean;
199
228
  advisorPlanGate?: boolean;
200
229
  advisorRedactSecrets?: boolean;
201
230
  advisorSessionSummary?: boolean;
202
231
  advisorToolPolicies?: AdvisorToolPolicies;
203
232
  advisorToolResultMaxBytes?: number;
204
233
  advisorToolResultMaxLines?: number;
234
+ advisorUntrackedContent?: boolean;
205
235
  alwaysOn?: boolean;
206
236
  contextMaxChars?: number;
207
237
  executor?: string;
@@ -219,6 +249,8 @@ const CONFIG_KEYS = new Set<keyof AdvisorConfig>([
219
249
  "advisorCustomInvocation",
220
250
  "advisorEffort",
221
251
  "advisorFailureGate",
252
+ "advisorGitContext",
253
+ "advisorGitContextMaxChars",
222
254
  "advisorHerdrIntegration",
223
255
  "advisorLoopThreshold",
224
256
  "advisorMaxCallsPerSession",
@@ -229,6 +261,8 @@ const CONFIG_KEYS = new Set<keyof AdvisorConfig>([
229
261
  "advisorToolResultMaxBytes",
230
262
  "advisorToolResultMaxLines",
231
263
  "advisorRedactSecrets",
264
+ "advisorUntrackedContent",
265
+ "advisorOutcomeLogging",
232
266
  "advisorToolPolicies",
233
267
  "contextMaxChars",
234
268
  "executor",
@@ -247,6 +281,8 @@ const BOOLEAN_CONFIG_KEYS = [
247
281
  "alwaysOn",
248
282
  "advisorHerdrIntegration",
249
283
  "advisorRedactSecrets",
284
+ "advisorUntrackedContent",
285
+ "advisorOutcomeLogging",
250
286
  ] as const;
251
287
  const STRING_CONFIG_KEYS = [
252
288
  "executor",
@@ -347,6 +383,11 @@ const validateNumericValues = (config: ConfigRecord, path: string) => {
347
383
  isValidToolResultMaxBytes,
348
384
  "a non-negative safe integer",
349
385
  ],
386
+ [
387
+ "advisorGitContextMaxChars",
388
+ isValidToolResultMaxBytes,
389
+ "a non-negative safe integer",
390
+ ],
350
391
  ];
351
392
  for (const [key, isValid, description] of numericRules) {
352
393
  if (config[key] !== undefined && !isValid(config[key])) {
@@ -385,6 +426,16 @@ export const validateConfig = (
385
426
  ) {
386
427
  invalidConfigValue(path, "gateFailureMode", GATE_FAILURE_MODES.join(", "));
387
428
  }
429
+ if (
430
+ config.advisorGitContext !== undefined &&
431
+ !isValidGitContextLevel(config.advisorGitContext)
432
+ ) {
433
+ invalidConfigValue(
434
+ path,
435
+ "advisorGitContext",
436
+ GIT_CONTEXT_LEVELS.join(", ")
437
+ );
438
+ }
388
439
  return true;
389
440
  };
390
441
 
@@ -411,7 +462,11 @@ const resetDefaults = () => {
411
462
  advisorToolResultMaxLinesRef = DEFAULT_ADVISOR_TOOL_RESULT_MAX_LINES;
412
463
  advisorToolResultMaxBytesRef = DEFAULT_ADVISOR_TOOL_RESULT_MAX_BYTES;
413
464
  advisorRedactSecretsRef = false;
465
+ advisorGitContextRef = "summary";
466
+ advisorGitContextMaxCharsRef = DEFAULT_ADVISOR_GIT_CONTEXT_MAX_CHARS;
414
467
  advisorToolPoliciesRef = {};
468
+ advisorOutcomeLoggingRef = false;
469
+ advisorUntrackedContentRef = false;
415
470
  };
416
471
 
417
472
  const applyOptionalConfig = <Key extends keyof AdvisorConfig>(
@@ -501,7 +556,18 @@ const applyConfig = (config: AdvisorConfig) => {
501
556
  "advisorRedactSecrets",
502
557
  setAdvisorRedactSecretsRef
503
558
  );
559
+ applyOptionalConfig(config, "advisorGitContext", setAdvisorGitContextRef);
560
+ applyOptionalConfig(
561
+ config,
562
+ "advisorGitContextMaxChars",
563
+ setAdvisorGitContextMaxCharsRef
564
+ );
504
565
  applyOptionalConfig(config, "advisorToolPolicies", setAdvisorToolPoliciesRef);
566
+ applyOptionalConfig(
567
+ config,
568
+ "advisorUntrackedContent",
569
+ setAdvisorUntrackedContentRef
570
+ );
505
571
  };
506
572
 
507
573
  const readConfig = (path: string): AdvisorConfig => {
@@ -517,13 +583,14 @@ const readConfig = (path: string): AdvisorConfig => {
517
583
  // loadConfig runs on every tool call and every consultation. Caching the parsed
518
584
  // file by path and stat identity removes that read and parse from the hot path
519
585
  // while still applying the full reset-then-apply sequence on each call.
520
- let configCache:
521
- | { config: AdvisorConfig; identity: string; path: string }
522
- | undefined;
586
+ const configCache = new Map<
587
+ string,
588
+ { config: AdvisorConfig; identity: string }
589
+ >();
523
590
 
524
591
  /** Drops the parsed-configuration cache; the next load re-reads from disk. */
525
592
  export const resetConfigCache = () => {
526
- configCache = undefined;
593
+ configCache.clear();
527
594
  };
528
595
 
529
596
  /**
@@ -542,34 +609,34 @@ const configIdentity = (path: string): string => {
542
609
 
543
610
  const readConfigCached = (path: string): AdvisorConfig => {
544
611
  const identity = configIdentity(path);
545
- const cached = configCache;
546
- if (cached && cached.path === path && cached.identity === identity) {
612
+ const cached = configCache.get(path);
613
+ if (cached?.identity === identity) {
547
614
  return cached.config;
548
615
  }
549
616
  const config = readConfig(path);
550
- configCache = { config, identity, path };
617
+ configCache.set(path, { config, identity });
551
618
  return config;
552
619
  };
553
620
 
554
- export const loadConfig = (ctx: ExtensionContext) => {
621
+ export const loadConfig = (_ctx: ExtensionContext) => {
555
622
  resetDefaults();
556
- const path = configPaths(ctx).find(
557
- (candidate): candidate is string =>
558
- candidate !== null && existsSync(candidate)
559
- );
560
- if (!path) {
561
- return null;
623
+ const global = join(getAgentDir(), "advisor.json");
624
+ const globalConfig = existsSync(global)
625
+ ? readConfigCached(global)
626
+ : undefined;
627
+ if (globalConfig) {
628
+ applyConfig(globalConfig);
562
629
  }
563
- applyConfig(readConfigCached(path));
564
- return path;
630
+ // Repository-controlled project configuration is never applied. Models,
631
+ // prompts, gates, budgets, disclosure, redaction, integrations, and consent
632
+ // remain under the user's global Pi configuration.
633
+ advisorOutcomeLoggingRef = globalConfig?.advisorOutcomeLogging === true;
634
+ return existsSync(global) ? global : null;
565
635
  };
566
636
 
567
- export const saveConfig = (ctx: ExtensionContext) => {
568
- const project = join(ctx.cwd, CONFIG_DIR_NAME, "advisor.json");
569
- const path =
570
- ctx.isProjectTrusted() && existsSync(project)
571
- ? project
572
- : join(getAgentDir(), "advisor.json");
637
+ /** Saves user-controlled configuration globally without outcome consent. */
638
+ export const saveConfig = (_ctx: ExtensionContext) => {
639
+ const path = join(getAgentDir(), "advisor.json");
573
640
  let existing: Record<string, unknown> = {};
574
641
  try {
575
642
  const parsed = JSON.parse(readFileSync(path, "utf8"));
@@ -600,12 +667,15 @@ export const saveConfig = (ctx: ExtensionContext) => {
600
667
  ...(advisorMaxCallsPerSessionRef === undefined
601
668
  ? {}
602
669
  : { advisorMaxCallsPerSession: advisorMaxCallsPerSessionRef }),
670
+ advisorGitContext: advisorGitContextRef,
671
+ advisorGitContextMaxChars: advisorGitContextMaxCharsRef,
603
672
  advisorHerdrIntegration: advisorHerdrIntegrationRef,
604
673
  advisorRedactSecrets: advisorRedactSecretsRef,
605
674
  advisorSessionSummary: advisorSessionSummaryRef,
606
675
  advisorToolPolicies: advisorToolPoliciesRef,
607
676
  advisorToolResultMaxBytes: advisorToolResultMaxBytesRef,
608
677
  advisorToolResultMaxLines: advisorToolResultMaxLinesRef,
678
+ advisorUntrackedContent: advisorUntrackedContentRef,
609
679
  alwaysOn: alwaysOnRef,
610
680
  gateFailureMode: advisorFailureModeRef,
611
681
  simpleMode: simpleModeRef,
@@ -615,6 +685,26 @@ export const saveConfig = (ctx: ExtensionContext) => {
615
685
  return path;
616
686
  };
617
687
 
688
+ /** Outcome logging is deliberately written only to the global Pi configuration. */
689
+ export const saveGlobalOutcomeLogging = (enabled: boolean) => {
690
+ const path = join(getAgentDir(), "advisor.json");
691
+ let existing: Record<string, unknown> = {};
692
+ try {
693
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
694
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
695
+ existing = parsed as Record<string, unknown>;
696
+ }
697
+ } catch {
698
+ /* create it */
699
+ }
700
+ writeFileSync(
701
+ path,
702
+ `${JSON.stringify({ ...existing, advisorOutcomeLogging: enabled }, null, 2)}\n`
703
+ );
704
+ resetConfigCache();
705
+ return path;
706
+ };
707
+
618
708
  export const parseArgs = (args: string): string | undefined => {
619
709
  let nextExecutor = executorRef;
620
710
  let nextAdvisor = advisorRef;
@@ -37,6 +37,8 @@ export const textFrom = (content: unknown): string =>
37
37
  const byteLength = (value: string) => Buffer.byteLength(value, "utf8");
38
38
 
39
39
  const REDACTION_MARKER = "[REDACTED SECRET]";
40
+ const PEM_BEGIN_PATTERN = /-----BEGIN(?: [A-Z0-9]+)? PRIVATE KEY-----/gi;
41
+ const PEM_END_PATTERN = /-----END(?: [A-Z0-9]+)? PRIVATE KEY-----/i;
40
42
  const SECRET_PATTERNS = [
41
43
  /-----BEGIN(?: [A-Z0-9]+)? PRIVATE KEY-----[\s\S]*?-----END(?: [A-Z0-9]+)? PRIVATE KEY-----/gi,
42
44
  /\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/gi,
@@ -46,9 +48,23 @@ const SECRET_PATTERNS = [
46
48
  /\b(?:aws_secret_access_key|aws_session_token)\s*[:=]\s*[^\s"'&,;)}\]]+/gi,
47
49
  ] as const;
48
50
 
51
+ const redactUnterminatedPem = (value: string): string => {
52
+ const begins = [...value.matchAll(PEM_BEGIN_PATTERN)];
53
+ const lastBegin = begins.at(-1);
54
+ if (lastBegin?.index === undefined) {
55
+ return value;
56
+ }
57
+ const hasEnd = PEM_END_PATTERN.test(
58
+ value.slice(lastBegin.index + lastBegin[0].length)
59
+ );
60
+ return hasEnd
61
+ ? value
62
+ : `${value.slice(0, lastBegin.index)}${REDACTION_MARKER}`;
63
+ };
64
+
49
65
  /** Redacts common credential forms locally; it is not a data-classification system. */
50
66
  export const redactSecrets = (value: string): string => {
51
- let redacted = value;
67
+ let redacted = redactUnterminatedPem(value);
52
68
  for (const pattern of SECRET_PATTERNS) {
53
69
  redacted = redacted.replace(pattern, (_match, scheme) =>
54
70
  typeof scheme === "string"
@@ -59,6 +75,23 @@ export const redactSecrets = (value: string): string => {
59
75
  return redacted;
60
76
  };
61
77
 
78
+ /** Redacts before a byte-safe cap so no secret fragment survives truncation. */
79
+ export const redactAndCapText = (
80
+ value: string,
81
+ maxBytes: number,
82
+ redact = true
83
+ ): string => {
84
+ const source = redact ? redactSecrets(value) : value;
85
+ let result = "";
86
+ for (const character of source) {
87
+ if (byteLength(result + character) > maxBytes) {
88
+ break;
89
+ }
90
+ result += character;
91
+ }
92
+ return result;
93
+ };
94
+
62
95
  export interface ToolResultTruncation {
63
96
  content: string;
64
97
  omittedLines: number;
package/src/git.ts ADDED
@@ -0,0 +1,190 @@
1
+ import { execFileSync } from "node:child_process";
2
+
3
+ /** How much repository change context may leave the machine. */
4
+ export type GitContextLevel = "off" | "summary" | "full";
5
+
6
+ export const GIT_CONTEXT_LEVELS: GitContextLevel[] = ["off", "summary", "full"];
7
+
8
+ export const isValidGitContextLevel = (
9
+ value: unknown
10
+ ): value is GitContextLevel =>
11
+ GIT_CONTEXT_LEVELS.includes(value as GitContextLevel);
12
+
13
+ const LEVEL_RANK: Record<GitContextLevel, number> = {
14
+ full: 2,
15
+ off: 0,
16
+ summary: 1,
17
+ };
18
+
19
+ /**
20
+ * The Executor may request no more repository context than the user configured.
21
+ * A model cannot widen its own disclosure allowance.
22
+ */
23
+ export const clampGitContextLevel = (
24
+ requested: GitContextLevel,
25
+ allowed: GitContextLevel
26
+ ): GitContextLevel =>
27
+ LEVEL_RANK[requested] <= LEVEL_RANK[allowed] ? requested : allowed;
28
+
29
+ export type GitContextStatus =
30
+ | "disabled"
31
+ | "no-changes"
32
+ | "collected"
33
+ | "not-a-repository"
34
+ | "failed";
35
+
36
+ export interface GitContextResult {
37
+ /** Why collection produced nothing, for diagnostics. Never sent verbatim. */
38
+ detail?: string;
39
+ /** The level actually collected after clamping. */
40
+ level: GitContextLevel;
41
+ status: GitContextStatus;
42
+ /** Collected context, not yet escaped or capped. Empty when nothing is disclosed. */
43
+ text: string;
44
+ }
45
+
46
+ /**
47
+ * Repository text is attacker-influenced and is embedded in a tagged region.
48
+ * A path can reproduce a closing tag verbatim: a directory named `x<` holding a
49
+ * file named `repository_changes>` prints as `x</repository_changes>`, which
50
+ * would end the region early and promote the remainder to trusted instructions.
51
+ */
52
+ export const escapeRepositoryText = (value: string) =>
53
+ value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
54
+
55
+ const TRUNCATION_NOTICE =
56
+ "\n[Repository context truncated: it exceeded the configured limit.]";
57
+
58
+ /** Caps the fully rendered region and states plainly that content was dropped. */
59
+ export const capRepositoryContext = (value: string, maxChars: number) => {
60
+ if (value.length <= maxChars) {
61
+ return { text: value, truncated: false };
62
+ }
63
+ return {
64
+ text: `${value.slice(0, Math.max(0, maxChars - TRUNCATION_NOTICE.length))}${TRUNCATION_NOTICE}`,
65
+ truncated: true,
66
+ };
67
+ };
68
+
69
+ // git's own empty-tree object, used to diff a repository with no commits yet.
70
+ const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
71
+ const GIT_TOTAL_TIMEOUT_MS = 5000;
72
+ const GIT_MAX_BUFFER = 16 * 1024 * 1024;
73
+
74
+ export type GitRunner = (args: string[], cwd: string) => string;
75
+
76
+ /**
77
+ * Collection runs several git commands. A per-command timeout alone would let a
78
+ * pathological repository block for the sum of them, so the budget is shared:
79
+ * each command may use only the time remaining before the overall deadline.
80
+ */
81
+ const deadlineRunner = (): GitRunner => {
82
+ const expiresAt = Date.now() + GIT_TOTAL_TIMEOUT_MS;
83
+ return (args, cwd) => {
84
+ const remaining = expiresAt - Date.now();
85
+ if (remaining <= 0) {
86
+ throw new Error("Git context collection exceeded its time budget.");
87
+ }
88
+ return execFileSync("git", args, {
89
+ cwd,
90
+ encoding: "utf8",
91
+ maxBuffer: GIT_MAX_BUFFER,
92
+ // Never use a shell: arguments are fixed and must not be re-parsed.
93
+ shell: false,
94
+ stdio: ["ignore", "pipe", "pipe"],
95
+ timeout: remaining,
96
+ windowsHide: true,
97
+ });
98
+ };
99
+ };
100
+
101
+ /** Resolves the base revision, falling back to the empty tree before any commit. */
102
+ const diffBase = (run: GitRunner, cwd: string): string => {
103
+ try {
104
+ run(["rev-parse", "--verify", "--quiet", "HEAD"], cwd);
105
+ return "HEAD";
106
+ } catch {
107
+ return EMPTY_TREE;
108
+ }
109
+ };
110
+
111
+ /**
112
+ * Collects working-tree changes relative to HEAD, covering staged and unstaged
113
+ * work. Untracked files are reported by name only and never by content.
114
+ *
115
+ * `summary` discloses file names, change status, and line counts. It must not
116
+ * include diff hunk headers: git derives those from surrounding file content,
117
+ * so they can reproduce a secret from a line the change never touched.
118
+ */
119
+ export const collectGitContext = (
120
+ cwd: string,
121
+ level: GitContextLevel,
122
+ maxChars: number,
123
+ redact: (value: string) => string = (value) => value,
124
+ run: GitRunner = deadlineRunner()
125
+ ): GitContextResult => {
126
+ if (level === "off" || maxChars <= 0) {
127
+ return { level: "off", status: "disabled", text: "" };
128
+ }
129
+ try {
130
+ run(["rev-parse", "--is-inside-work-tree"], cwd);
131
+ } catch (error) {
132
+ return {
133
+ detail: error instanceof Error ? error.message : String(error),
134
+ level,
135
+ status: "not-a-repository",
136
+ text: "",
137
+ };
138
+ }
139
+
140
+ try {
141
+ const base = diffBase(run, cwd);
142
+ const nameStatus = run(["diff", "--name-status", base], cwd).trim();
143
+ const shortstat = run(["diff", "--shortstat", base], cwd).trim();
144
+ const untracked = run(
145
+ ["ls-files", "--others", "--exclude-standard"],
146
+ cwd
147
+ ).trim();
148
+
149
+ if (!(nameStatus || untracked)) {
150
+ return { level, status: "no-changes", text: "" };
151
+ }
152
+
153
+ const sections = [
154
+ "Working-tree changes against the last commit (staged and unstaged).",
155
+ nameStatus ? `Changed files:\n${nameStatus}` : "",
156
+ shortstat ? `Totals: ${shortstat}` : "",
157
+ untracked
158
+ ? `Untracked files (names only, contents withheld):\n${untracked}`
159
+ : "",
160
+ ];
161
+
162
+ if (level === "full") {
163
+ const patch = run(["diff", base], cwd);
164
+ sections.push(
165
+ patch.trim()
166
+ ? `Patch:\n${patch}`
167
+ : "Patch: (no tracked-file content changes)"
168
+ );
169
+ } else {
170
+ sections.push(
171
+ "Full patch withheld by configuration; file contents were not disclosed."
172
+ );
173
+ }
174
+
175
+ // Redaction runs before the caller caps the region, so a limit can never
176
+ // split a secret and leave a readable fragment behind.
177
+ return {
178
+ level,
179
+ status: "collected",
180
+ text: redact(sections.filter(Boolean).join("\n\n")),
181
+ };
182
+ } catch (error) {
183
+ return {
184
+ detail: error instanceof Error ? error.message : String(error),
185
+ level,
186
+ status: "failed",
187
+ text: "",
188
+ };
189
+ }
190
+ };
package/src/herdr.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import net from "node:net";
2
- import { advisorHerdrIntegrationRef } from "./config.js";
2
+ import { getAdvisorSettings } from "./config.js";
3
+ import { redactSecrets } from "./conversation.js";
3
4
 
4
5
  const SOURCE = "pi-advisor:advisor-activity";
5
6
  const BLOCK_SOURCE = "pi-advisor:advisor-block";
@@ -41,7 +42,7 @@ const isControlCharacter = (character: string) =>
41
42
  character <= "\u001f" || character === "\u007f";
42
43
 
43
44
  const cleanNotification = (value: string, max: number) =>
44
- [...value]
45
+ [...redactSecrets(value)]
45
46
  .map((character) => (isControlCharacter(character) ? " " : character))
46
47
  .join("")
47
48
  .replace(/\s+/g, " ")
@@ -167,7 +168,7 @@ export class HerdrAdvisorBlock {
167
168
  return;
168
169
  }
169
170
  this.#blocked = true;
170
- this.safeReport({ blocked: reason });
171
+ this.safeReport({ blocked: cleanNotification(reason, 200) });
171
172
  }
172
173
 
173
174
  clear() {
@@ -176,9 +177,8 @@ export class HerdrAdvisorBlock {
176
177
  if (!wasBlocked) {
177
178
  return;
178
179
  }
179
- if (!this.enabled()) {
180
- return;
181
- }
180
+ // Clearing previously reported state is a de-escalation and must still be
181
+ // delivered if integration was disabled after the block was reported.
182
182
  try {
183
183
  this.report({
184
184
  id: `${BLOCK_SOURCE}:${nextSequence()}`,
@@ -218,7 +218,7 @@ export class HerdrAdvisorBlock {
218
218
  }
219
219
 
220
220
  export const notifyHerdrAdvisorFailure = (title: string, body: string) => {
221
- if (!advisorHerdrIntegrationRef) {
221
+ if (!getAdvisorSettings().herdrIntegration) {
222
222
  return;
223
223
  }
224
224
  try {
@@ -230,9 +230,9 @@ export const notifyHerdrAdvisorFailure = (title: string, body: string) => {
230
230
 
231
231
  export const herdrAdvisorActivity = new HerdrAdvisorActivity(
232
232
  sendToHerdr,
233
- () => advisorHerdrIntegrationRef
233
+ () => getAdvisorSettings().herdrIntegration
234
234
  );
235
235
  export const herdrAdvisorBlock = new HerdrAdvisorBlock(
236
236
  sendToHerdr,
237
- () => advisorHerdrIntegrationRef
237
+ () => getAdvisorSettings().herdrIntegration
238
238
  );
@@ -0,0 +1,85 @@
1
+ import { createHmac, randomBytes } from "node:crypto";
2
+ import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
+
6
+ export type OutcomeAdoption = "followed" | "not-followed" | "unknown";
7
+ export type OutcomeValidation = "passed" | "failed" | "not-run" | "unknown";
8
+ export type OutcomeTrigger =
9
+ | "manual"
10
+ | "executor-requested"
11
+ | "repeated-tool-call";
12
+ export const ADOPTIONS: OutcomeAdoption[] = [
13
+ "followed",
14
+ "not-followed",
15
+ "unknown",
16
+ ];
17
+ export const VALIDATIONS: OutcomeValidation[] = [
18
+ "passed",
19
+ "failed",
20
+ "not-run",
21
+ "unknown",
22
+ ];
23
+ export interface OutcomeRecord {
24
+ adoption: OutcomeAdoption;
25
+ adviceHash: string;
26
+ timestamp: string;
27
+ trigger: OutcomeTrigger;
28
+ v: 1;
29
+ validationStatus: OutcomeValidation;
30
+ }
31
+ const MAX_LOG_BYTES = 1024 * 1024;
32
+ const statePath = () => join(getAgentDir(), "advisor-outcomes-salt");
33
+ export const outcomeLogPath = () =>
34
+ join(getAgentDir(), "advisor-outcomes.jsonl");
35
+
36
+ const salt = async () => {
37
+ const path = statePath();
38
+ try {
39
+ return await readFile(path);
40
+ } catch {
41
+ await mkdir(getAgentDir(), { mode: 0o700, recursive: true });
42
+ const value = randomBytes(32);
43
+ const temporary = `${path}.${process.pid}.${Date.now()}`;
44
+ await writeFile(temporary, value, { mode: 0o600 });
45
+ await rename(temporary, path);
46
+ await chmod(path, 0o600);
47
+ return value;
48
+ }
49
+ };
50
+ export const adviceDigest = (advice: string, key: Buffer) =>
51
+ createHmac("sha256", key).update(advice).digest("hex").slice(0, 16);
52
+
53
+ /** Best-effort, global-only, minimal persistent telemetry. */
54
+ export const appendOutcome = async (
55
+ record: Omit<OutcomeRecord, "adviceHash" | "timestamp" | "v"> & {
56
+ advice: string;
57
+ }
58
+ ) => {
59
+ const path = outcomeLogPath();
60
+ await mkdir(getAgentDir(), { mode: 0o700, recursive: true });
61
+ let previous = "";
62
+ try {
63
+ previous = await readFile(path, "utf8");
64
+ } catch {
65
+ /* new log */
66
+ }
67
+ const next: OutcomeRecord = {
68
+ adoption: record.adoption,
69
+ adviceHash: adviceDigest(record.advice, await salt()),
70
+ timestamp: new Date().toISOString(),
71
+ trigger: record.trigger,
72
+ v: 1,
73
+ validationStatus: record.validationStatus,
74
+ };
75
+ const line = `${JSON.stringify(next)}\n`;
76
+ const retained =
77
+ Buffer.byteLength(previous) + Buffer.byteLength(line) > MAX_LOG_BYTES
78
+ ? ""
79
+ : previous;
80
+ const temporary = `${path}.${process.pid}.${Date.now()}`;
81
+ await writeFile(temporary, retained + line, { mode: 0o600 });
82
+ await rename(temporary, path);
83
+ await chmod(path, 0o600);
84
+ return next;
85
+ };