pi-advisor-flow 0.2.3 → 0.2.6

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
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import {
4
4
  CONFIG_DIR_NAME,
@@ -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[] = [
@@ -45,13 +51,19 @@ export let advisorBlockOnBlockedRef = true;
45
51
  export let advisorAutoLoopGateRef = true;
46
52
  export let advisorLoopThresholdRef = 3;
47
53
  export let advisorMaxCallsPerSessionRef: number | undefined;
48
- export let advisorSessionSummaryRef = true;
54
+ export let advisorSessionSummaryRef = false;
55
+ export let simpleModeRef = false;
56
+ export let alwaysOnRef = false;
49
57
  export let advisorFailureModeRef: GateFailureMode = "block-session";
50
58
  export let advisorHerdrIntegrationRef = true;
51
59
  export let advisorToolResultMaxLinesRef = DEFAULT_ADVISOR_TOOL_RESULT_MAX_LINES;
52
60
  export let advisorToolResultMaxBytesRef = DEFAULT_ADVISOR_TOOL_RESULT_MAX_BYTES;
53
61
  export let advisorRedactSecretsRef = false;
62
+ export let advisorGitContextRef: GitContextLevel = "summary";
63
+ export let advisorGitContextMaxCharsRef = DEFAULT_ADVISOR_GIT_CONTEXT_MAX_CHARS;
54
64
  export let advisorToolPoliciesRef: AdvisorToolPolicies = {};
65
+ export let advisorOutcomeLoggingRef = false;
66
+ export let advisorUntrackedContentRef = false;
55
67
 
56
68
  export const setExecutorRef = (ref: string) => {
57
69
  executorRef = ref;
@@ -107,6 +119,14 @@ export const setAdvisorMaxCallsPerSessionRef = (value: number | undefined) => {
107
119
  export const setAdvisorSessionSummaryRef = (enabled: boolean) => {
108
120
  advisorSessionSummaryRef = enabled;
109
121
  };
122
+ export const setSimpleModeRef = (enabled: boolean) => {
123
+ simpleModeRef = enabled;
124
+ };
125
+ export const setAlwaysOnRef = (enabled: boolean) => {
126
+ alwaysOnRef = enabled;
127
+ };
128
+ /** Whether advanced Advisor automation is suppressed for this session. */
129
+ export const isSimpleMode = () => simpleModeRef;
110
130
  export const isValidGateFailureMode = (
111
131
  value: unknown
112
132
  ): value is GateFailureMode =>
@@ -131,15 +151,28 @@ export const setAdvisorToolResultMaxBytesRef = (value: number) => {
131
151
  export const setAdvisorRedactSecretsRef = (enabled: boolean) => {
132
152
  advisorRedactSecretsRef = enabled;
133
153
  };
154
+ export const setAdvisorGitContextRef = (level: GitContextLevel) => {
155
+ advisorGitContextRef = level;
156
+ };
157
+ export const setAdvisorGitContextMaxCharsRef = (value: number) => {
158
+ advisorGitContextMaxCharsRef = value;
159
+ };
134
160
  export const setAdvisorToolPoliciesRef = (policies: AdvisorToolPolicies) => {
135
161
  advisorToolPoliciesRef = { ...policies };
136
162
  };
163
+ export const setAdvisorOutcomeLoggingRef = (enabled: boolean) => {
164
+ advisorOutcomeLoggingRef = enabled;
165
+ };
166
+ export const setAdvisorUntrackedContentRef = (enabled: boolean) => {
167
+ advisorUntrackedContentRef = enabled;
168
+ };
137
169
 
138
170
  /**
139
171
  * Returns the current live settings state. Use this at UI boundaries instead of
140
172
  * imported mutable bindings, which can be snapshotted by extension loaders.
141
173
  */
142
174
  export const getAdvisorSettings = () => ({
175
+ alwaysOn: alwaysOnRef,
143
176
  autoLoopGate: advisorAutoLoopGateRef,
144
177
  blockOnBlocked: advisorBlockOnBlockedRef,
145
178
  collapseResponses: advisorCollapseResponsesRef,
@@ -149,15 +182,20 @@ export const getAdvisorSettings = () => ({
149
182
  effort: advisorEffortRef,
150
183
  failureGate: advisorFailureGateRef,
151
184
  failureMode: advisorFailureModeRef,
185
+ gitContext: advisorGitContextRef,
186
+ gitContextMaxChars: advisorGitContextMaxCharsRef,
152
187
  herdrIntegration: advisorHerdrIntegrationRef,
153
188
  loopThreshold: advisorLoopThresholdRef,
154
189
  maxCallsPerSession: advisorMaxCallsPerSessionRef,
190
+ outcomeLogging: advisorOutcomeLoggingRef,
155
191
  planGate: advisorPlanGateRef,
156
192
  redactSecrets: advisorRedactSecretsRef,
157
193
  sessionSummary: advisorSessionSummaryRef,
194
+ simpleMode: simpleModeRef,
158
195
  toolPolicies: { ...advisorToolPoliciesRef },
159
196
  toolResultMaxBytes: advisorToolResultMaxBytesRef,
160
197
  toolResultMaxLines: advisorToolResultMaxLinesRef,
198
+ untrackedContent: advisorUntrackedContentRef,
161
199
  });
162
200
 
163
201
  export const splitRef = (ref: string): [string, string] => {
@@ -181,19 +219,25 @@ export interface AdvisorConfig {
181
219
  advisorCustomInvocation?: string;
182
220
  advisorEffort?: string;
183
221
  advisorFailureGate?: boolean;
222
+ advisorGitContext?: GitContextLevel;
223
+ advisorGitContextMaxChars?: number;
184
224
  advisorHerdrIntegration?: boolean;
185
225
  advisorLoopThreshold?: number;
186
226
  advisorMaxCallsPerSession?: number;
227
+ advisorOutcomeLogging?: boolean;
187
228
  advisorPlanGate?: boolean;
188
229
  advisorRedactSecrets?: boolean;
189
230
  advisorSessionSummary?: boolean;
190
231
  advisorToolPolicies?: AdvisorToolPolicies;
191
232
  advisorToolResultMaxBytes?: number;
192
233
  advisorToolResultMaxLines?: number;
234
+ advisorUntrackedContent?: boolean;
235
+ alwaysOn?: boolean;
193
236
  contextMaxChars?: number;
194
237
  executor?: string;
195
238
  executorEffort?: string;
196
239
  gateFailureMode?: GateFailureMode;
240
+ simpleMode?: boolean;
197
241
  }
198
242
 
199
243
  const CONFIG_KEYS = new Set<keyof AdvisorConfig>([
@@ -205,14 +249,20 @@ const CONFIG_KEYS = new Set<keyof AdvisorConfig>([
205
249
  "advisorCustomInvocation",
206
250
  "advisorEffort",
207
251
  "advisorFailureGate",
252
+ "advisorGitContext",
253
+ "advisorGitContextMaxChars",
208
254
  "advisorHerdrIntegration",
209
255
  "advisorLoopThreshold",
210
256
  "advisorMaxCallsPerSession",
211
257
  "advisorPlanGate",
212
258
  "advisorSessionSummary",
259
+ "simpleMode",
260
+ "alwaysOn",
213
261
  "advisorToolResultMaxBytes",
214
262
  "advisorToolResultMaxLines",
215
263
  "advisorRedactSecrets",
264
+ "advisorUntrackedContent",
265
+ "advisorOutcomeLogging",
216
266
  "advisorToolPolicies",
217
267
  "contextMaxChars",
218
268
  "executor",
@@ -227,8 +277,12 @@ const BOOLEAN_CONFIG_KEYS = [
227
277
  "advisorBlockOnBlocked",
228
278
  "advisorAutoLoopGate",
229
279
  "advisorSessionSummary",
280
+ "simpleMode",
281
+ "alwaysOn",
230
282
  "advisorHerdrIntegration",
231
283
  "advisorRedactSecrets",
284
+ "advisorUntrackedContent",
285
+ "advisorOutcomeLogging",
232
286
  ] as const;
233
287
  const STRING_CONFIG_KEYS = [
234
288
  "executor",
@@ -329,6 +383,11 @@ const validateNumericValues = (config: ConfigRecord, path: string) => {
329
383
  isValidToolResultMaxBytes,
330
384
  "a non-negative safe integer",
331
385
  ],
386
+ [
387
+ "advisorGitContextMaxChars",
388
+ isValidToolResultMaxBytes,
389
+ "a non-negative safe integer",
390
+ ],
332
391
  ];
333
392
  for (const [key, isValid, description] of numericRules) {
334
393
  if (config[key] !== undefined && !isValid(config[key])) {
@@ -367,6 +426,16 @@ export const validateConfig = (
367
426
  ) {
368
427
  invalidConfigValue(path, "gateFailureMode", GATE_FAILURE_MODES.join(", "));
369
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
+ }
370
439
  return true;
371
440
  };
372
441
 
@@ -385,13 +454,19 @@ const resetDefaults = () => {
385
454
  advisorAutoLoopGateRef = true;
386
455
  advisorLoopThresholdRef = 3;
387
456
  advisorMaxCallsPerSessionRef = undefined;
388
- advisorSessionSummaryRef = true;
457
+ advisorSessionSummaryRef = false;
458
+ simpleModeRef = false;
459
+ alwaysOnRef = false;
389
460
  advisorFailureModeRef = "block-session";
390
461
  advisorHerdrIntegrationRef = true;
391
462
  advisorToolResultMaxLinesRef = DEFAULT_ADVISOR_TOOL_RESULT_MAX_LINES;
392
463
  advisorToolResultMaxBytesRef = DEFAULT_ADVISOR_TOOL_RESULT_MAX_BYTES;
393
464
  advisorRedactSecretsRef = false;
465
+ advisorGitContextRef = "summary";
466
+ advisorGitContextMaxCharsRef = DEFAULT_ADVISOR_GIT_CONTEXT_MAX_CHARS;
394
467
  advisorToolPoliciesRef = {};
468
+ advisorOutcomeLoggingRef = false;
469
+ advisorUntrackedContentRef = false;
395
470
  };
396
471
 
397
472
  const applyOptionalConfig = <Key extends keyof AdvisorConfig>(
@@ -458,6 +533,8 @@ const applyConfig = (config: AdvisorConfig) => {
458
533
  "advisorSessionSummary",
459
534
  setAdvisorSessionSummaryRef
460
535
  );
536
+ applyOptionalConfig(config, "simpleMode", setSimpleModeRef);
537
+ applyOptionalConfig(config, "alwaysOn", setAlwaysOnRef);
461
538
  applyOptionalConfig(config, "gateFailureMode", setAdvisorFailureModeRef);
462
539
  applyOptionalConfig(
463
540
  config,
@@ -479,7 +556,18 @@ const applyConfig = (config: AdvisorConfig) => {
479
556
  "advisorRedactSecrets",
480
557
  setAdvisorRedactSecretsRef
481
558
  );
559
+ applyOptionalConfig(config, "advisorGitContext", setAdvisorGitContextRef);
560
+ applyOptionalConfig(
561
+ config,
562
+ "advisorGitContextMaxChars",
563
+ setAdvisorGitContextMaxCharsRef
564
+ );
482
565
  applyOptionalConfig(config, "advisorToolPolicies", setAdvisorToolPoliciesRef);
566
+ applyOptionalConfig(
567
+ config,
568
+ "advisorUntrackedContent",
569
+ setAdvisorUntrackedContentRef
570
+ );
483
571
  };
484
572
 
485
573
  const readConfig = (path: string): AdvisorConfig => {
@@ -492,19 +580,64 @@ const readConfig = (path: string): AdvisorConfig => {
492
580
  }
493
581
  };
494
582
 
583
+ // loadConfig runs on every tool call and every consultation. Caching the parsed
584
+ // file by path and stat identity removes that read and parse from the hot path
585
+ // while still applying the full reset-then-apply sequence on each call.
586
+ let configCache:
587
+ | { config: AdvisorConfig; identity: string; path: string }
588
+ | undefined;
589
+
590
+ /** Drops the parsed-configuration cache; the next load re-reads from disk. */
591
+ export const resetConfigCache = () => {
592
+ configCache = undefined;
593
+ };
594
+
595
+ /**
596
+ * Nanosecond timestamps plus size and inode so a same-millisecond or
597
+ * same-size external rewrite cannot be served from the cache. An unstattable
598
+ * path yields a unique value, which always forces a re-read.
599
+ */
600
+ const configIdentity = (path: string): string => {
601
+ try {
602
+ const stats = statSync(path, { bigint: true });
603
+ return `${stats.mtimeNs}:${stats.ctimeNs}:${stats.size}:${stats.ino}:${stats.dev}`;
604
+ } catch {
605
+ return `unstattable:${process.hrtime.bigint()}`;
606
+ }
607
+ };
608
+
609
+ const readConfigCached = (path: string): AdvisorConfig => {
610
+ const identity = configIdentity(path);
611
+ const cached = configCache;
612
+ if (cached && cached.path === path && cached.identity === identity) {
613
+ return cached.config;
614
+ }
615
+ const config = readConfig(path);
616
+ configCache = { config, identity, path };
617
+ return config;
618
+ };
619
+
495
620
  export const loadConfig = (ctx: ExtensionContext) => {
496
621
  resetDefaults();
497
- const path = configPaths(ctx).find(
622
+ const paths = configPaths(ctx);
623
+ const path = paths.find(
498
624
  (candidate): candidate is string =>
499
625
  candidate !== null && existsSync(candidate)
500
626
  );
501
- if (!path) {
502
- return null;
627
+ if (path) {
628
+ applyConfig(readConfigCached(path));
503
629
  }
504
- applyConfig(readConfig(path));
505
- return path;
630
+ // Persistent outcome consent is global-only: project configuration cannot enable it.
631
+ const global = join(getAgentDir(), "advisor.json");
632
+ const globalConfig = existsSync(global)
633
+ ? readConfigCached(global)
634
+ : undefined;
635
+ // Never apply this field through project-first configuration selection.
636
+ advisorOutcomeLoggingRef = globalConfig?.advisorOutcomeLogging === true;
637
+ return path ?? null;
506
638
  };
507
639
 
640
+ /** Saves the ordinary project-preferred configuration without outcome consent. */
508
641
  export const saveConfig = (ctx: ExtensionContext) => {
509
642
  const project = join(ctx.cwd, CONFIG_DIR_NAME, "advisor.json");
510
643
  const path =
@@ -541,15 +674,41 @@ export const saveConfig = (ctx: ExtensionContext) => {
541
674
  ...(advisorMaxCallsPerSessionRef === undefined
542
675
  ? {}
543
676
  : { advisorMaxCallsPerSession: advisorMaxCallsPerSessionRef }),
677
+ advisorGitContext: advisorGitContextRef,
678
+ advisorGitContextMaxChars: advisorGitContextMaxCharsRef,
544
679
  advisorHerdrIntegration: advisorHerdrIntegrationRef,
545
680
  advisorRedactSecrets: advisorRedactSecretsRef,
546
681
  advisorSessionSummary: advisorSessionSummaryRef,
547
682
  advisorToolPolicies: advisorToolPoliciesRef,
548
683
  advisorToolResultMaxBytes: advisorToolResultMaxBytesRef,
549
684
  advisorToolResultMaxLines: advisorToolResultMaxLinesRef,
685
+ advisorUntrackedContent: advisorUntrackedContentRef,
686
+ alwaysOn: alwaysOnRef,
550
687
  gateFailureMode: advisorFailureModeRef,
688
+ simpleMode: simpleModeRef,
551
689
  };
552
690
  writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`);
691
+ resetConfigCache();
692
+ return path;
693
+ };
694
+
695
+ /** Outcome logging is deliberately written only to the global Pi configuration. */
696
+ export const saveGlobalOutcomeLogging = (enabled: boolean) => {
697
+ const path = join(getAgentDir(), "advisor.json");
698
+ let existing: Record<string, unknown> = {};
699
+ try {
700
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
701
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
702
+ existing = parsed as Record<string, unknown>;
703
+ }
704
+ } catch {
705
+ /* create it */
706
+ }
707
+ writeFileSync(
708
+ path,
709
+ `${JSON.stringify({ ...existing, advisorOutcomeLogging: enabled }, null, 2)}\n`
710
+ );
711
+ resetConfigCache();
553
712
  return path;
554
713
  };
555
714
 
@@ -59,6 +59,23 @@ export const redactSecrets = (value: string): string => {
59
59
  return redacted;
60
60
  };
61
61
 
62
+ /** Redacts before a byte-safe cap so no secret fragment survives truncation. */
63
+ export const redactAndCapText = (
64
+ value: string,
65
+ maxBytes: number,
66
+ redact = true
67
+ ): string => {
68
+ const source = redact ? redactSecrets(value) : value;
69
+ let result = "";
70
+ for (const character of source) {
71
+ if (byteLength(result + character) > maxBytes) {
72
+ break;
73
+ }
74
+ result += character;
75
+ }
76
+ return result;
77
+ };
78
+
62
79
  export interface ToolResultTruncation {
63
80
  content: string;
64
81
  omittedLines: number;
@@ -256,10 +273,18 @@ const selectRecentEntries = (entries: string[], maxChars: number): string => {
256
273
  return joined;
257
274
  }
258
275
  const selected: string[] = [];
276
+ // Track the joined length instead of re-joining the accumulator each step;
277
+ // long branches with a large maxChars are otherwise quadratic in bytes.
278
+ let selectedLength = 0;
259
279
  for (let index = entries.length - 1; index >= 0; index -= 1) {
260
- const candidate = [entries[index], ...selected].join(separator);
261
- if (candidate.length <= maxChars || selected.length === 0) {
262
- selected.unshift(entries[index]);
280
+ const entry = entries[index];
281
+ const candidateLength =
282
+ selectedLength +
283
+ entry.length +
284
+ (selected.length === 0 ? 0 : separator.length);
285
+ if (candidateLength <= maxChars || selected.length === 0) {
286
+ selected.unshift(entry);
287
+ selectedLength = candidateLength;
263
288
  } else {
264
289
  break;
265
290
  }
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
+ };
@@ -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
+ };