pi-advisor-flow 0.9.2-dev.4 → 0.10.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +16 -7
  3. package/dist/index.js +938 -287
  4. package/package.json +2 -2
  5. package/src/commands/activation-preparation.ts +4 -0
  6. package/src/commands/manual-consultation.ts +20 -3
  7. package/src/commands/model-commands.ts +5 -1
  8. package/src/commands/model-picker.ts +44 -2
  9. package/src/commands/outcome-stats.ts +98 -0
  10. package/src/commands/registration.ts +2 -0
  11. package/src/commands/renderers.ts +51 -0
  12. package/src/commands/runtime.ts +9 -1
  13. package/src/commands/settings-persistence.ts +4 -0
  14. package/src/commands/types.ts +5 -2
  15. package/src/config/defaults.ts +13 -0
  16. package/src/config/schema.ts +14 -0
  17. package/src/config/state.ts +10 -0
  18. package/src/config/types.ts +2 -0
  19. package/src/config.ts +4 -0
  20. package/src/follow-up.ts +85 -0
  21. package/src/outcome-stats.ts +154 -0
  22. package/src/outcomes.ts +8 -1
  23. package/src/preferences.ts +90 -12
  24. package/src/session-state.ts +33 -0
  25. package/src/tools/consult-context.ts +12 -1
  26. package/src/tools/consultation.ts +344 -47
  27. package/src/tools/jev-turn-gate.ts +2 -0
  28. package/src/tools/loop-gate.ts +2 -0
  29. package/src/tools/model-access.ts +7 -3
  30. package/src/tools/prompts.ts +28 -2
  31. package/src/tools/register-ask-advisor.ts +110 -23
  32. package/src/tools/register-lifecycle.ts +11 -0
  33. package/src/tools/register-renderers.ts +40 -6
  34. package/src/tools/render-advisor-result.ts +6 -0
  35. package/src/tools/render-common.ts +6 -2
  36. package/src/tools/types.ts +5 -0
  37. package/src/ui/settings-items.ts +43 -0
  38. package/src/ui/settings-mutations.ts +9 -0
  39. package/src/ui/settings-selector.ts +9 -0
  40. package/src/ui/types.ts +4 -0
@@ -0,0 +1,85 @@
1
+ import type { Message } from "@earendil-works/pi-ai/compat";
2
+
3
+ export const FOLLOW_UP_TTL_MS = 5 * 60 * 1000;
4
+ export const FOLLOW_UP_MAX_DEPTH = 3;
5
+ export const FOLLOW_UP_MAX_TOOL_CALLS = 3;
6
+
7
+ export interface AdvisorFollowUpPayload {
8
+ depth: number;
9
+ messages: Message[];
10
+ model: string;
11
+ privacyKey: string;
12
+ redacted: boolean;
13
+ systemPrompt: string;
14
+ }
15
+
16
+ export interface AdvisorFollowUpLookup {
17
+ payload?: AdvisorFollowUpPayload;
18
+ reason?: string;
19
+ }
20
+
21
+ interface StoredFollowUpPayload extends AdvisorFollowUpPayload {
22
+ expiresAt: number;
23
+ toolCallsSince: number;
24
+ }
25
+
26
+ const copy = <Value>(value: Value): Value => structuredClone(value);
27
+
28
+ export class AdvisorFollowUpCache {
29
+ readonly #entries = new Map<string, StoredFollowUpPayload>();
30
+ readonly #now: () => number;
31
+
32
+ constructor(now: () => number = Date.now) {
33
+ this.#now = now;
34
+ }
35
+
36
+ clear() {
37
+ this.#entries.clear();
38
+ }
39
+
40
+ advanceToolCall() {
41
+ const now = this.#now();
42
+ for (const [adviceId, entry] of this.#entries) {
43
+ if (
44
+ now >= entry.expiresAt ||
45
+ entry.toolCallsSince + 1 >= FOLLOW_UP_MAX_TOOL_CALLS
46
+ ) {
47
+ this.#entries.delete(adviceId);
48
+ continue;
49
+ }
50
+ entry.toolCallsSince += 1;
51
+ }
52
+ }
53
+
54
+ capture(
55
+ adviceId: string,
56
+ payload: AdvisorFollowUpPayload,
57
+ replacesAdviceId?: string
58
+ ) {
59
+ if (replacesAdviceId) {
60
+ this.#entries.delete(replacesAdviceId);
61
+ }
62
+ this.#entries.set(adviceId, {
63
+ ...copy(payload),
64
+ expiresAt: this.#now() + FOLLOW_UP_TTL_MS,
65
+ toolCallsSince: 0,
66
+ });
67
+ }
68
+
69
+ get(adviceId: string): AdvisorFollowUpLookup {
70
+ const entry = this.#entries.get(adviceId);
71
+ if (!entry) {
72
+ return { reason: "the adviceId is unknown or no longer cached" };
73
+ }
74
+ if (this.#now() >= entry.expiresAt) {
75
+ this.#entries.delete(adviceId);
76
+ return { reason: "the cached payload has expired" };
77
+ }
78
+ if (entry.depth >= FOLLOW_UP_MAX_DEPTH) {
79
+ return {
80
+ reason: `the follow-up chain is limited to ${FOLLOW_UP_MAX_DEPTH} follow-ups`,
81
+ };
82
+ }
83
+ return { payload: copy(entry) };
84
+ }
85
+ }
@@ -0,0 +1,154 @@
1
+ import { readFile } from "node:fs/promises";
2
+
3
+ import { isRecord, isString } from "./content-utils.ts";
4
+ import {
5
+ ADOPTIONS,
6
+ OUTCOME_TRIGGERS,
7
+ VALIDATIONS,
8
+ outcomeLogPath,
9
+ } from "./outcomes.ts";
10
+ import type {
11
+ OutcomeAdoption,
12
+ OutcomeRecord,
13
+ OutcomeTrigger,
14
+ } from "./outcomes.ts";
15
+
16
+ interface OutcomeValidationStats {
17
+ failed: number;
18
+ notRun: number;
19
+ passed: number;
20
+ passRate?: number;
21
+ total: number;
22
+ unknown: number;
23
+ }
24
+ export interface OutcomeStats {
25
+ adoption: Record<OutcomeAdoption, number>;
26
+ byTrigger: Record<OutcomeTrigger, number>;
27
+ distinctAdvices: number;
28
+ firstTimestamp?: string;
29
+ lastTimestamp?: string;
30
+ malformedLines: number;
31
+ total: number;
32
+ validationByAdoption: Record<OutcomeAdoption, OutcomeValidationStats>;
33
+ }
34
+ export interface OutcomeLogRead {
35
+ malformedLines: number;
36
+ missing: boolean;
37
+ records: OutcomeRecord[];
38
+ }
39
+
40
+ const ADOPTION_SET = new Set<string>(ADOPTIONS);
41
+ const VALIDATION_SET = new Set<string>(VALIDATIONS);
42
+ const TRIGGER_SET = new Set<string>(OUTCOME_TRIGGERS);
43
+ const isErrnoException = (error: unknown): error is NodeJS.ErrnoException =>
44
+ error instanceof Error && "code" in error;
45
+
46
+ const isOutcomeRecord = (value: unknown): value is OutcomeRecord =>
47
+ isRecord(value) &&
48
+ value.v === 1 &&
49
+ isString(value.adoption) &&
50
+ ADOPTION_SET.has(value.adoption) &&
51
+ isString(value.adviceHash) &&
52
+ isString(value.timestamp) &&
53
+ Number.isFinite(Date.parse(value.timestamp)) &&
54
+ isString(value.trigger) &&
55
+ TRIGGER_SET.has(value.trigger) &&
56
+ isString(value.validationStatus) &&
57
+ VALIDATION_SET.has(value.validationStatus);
58
+
59
+ export const parseOutcomeLog = (text: string): OutcomeLogRead => {
60
+ const records: OutcomeRecord[] = [];
61
+ let malformedLines = 0;
62
+ for (const line of text.split(/\r?\n/u)) {
63
+ if (!line.trim()) {
64
+ continue;
65
+ }
66
+ try {
67
+ const value: unknown = JSON.parse(line);
68
+ if (isOutcomeRecord(value)) {
69
+ records.push(value);
70
+ } else {
71
+ malformedLines += 1;
72
+ }
73
+ } catch {
74
+ malformedLines += 1;
75
+ }
76
+ }
77
+ return { malformedLines, missing: false, records };
78
+ };
79
+
80
+ export const readOutcomeLog = async (
81
+ path = outcomeLogPath()
82
+ ): Promise<OutcomeLogRead> => {
83
+ try {
84
+ return parseOutcomeLog(await readFile(path, "utf-8"));
85
+ } catch (error) {
86
+ if (isErrnoException(error) && error.code === "ENOENT") {
87
+ return { malformedLines: 0, missing: true, records: [] };
88
+ }
89
+ throw error;
90
+ }
91
+ };
92
+
93
+ const emptyValidationStats = (): OutcomeValidationStats => ({
94
+ failed: 0,
95
+ notRun: 0,
96
+ passed: 0,
97
+ total: 0,
98
+ unknown: 0,
99
+ });
100
+
101
+ export const aggregateOutcomeRecords = (
102
+ records: readonly OutcomeRecord[],
103
+ malformedLines = 0
104
+ ): OutcomeStats => {
105
+ // SAFETY: ADOPTIONS enumerates every key in the returned adoption record.
106
+ const adoption = Object.fromEntries(
107
+ ADOPTIONS.map((value) => [value, 0])
108
+ ) as Record<OutcomeAdoption, number>;
109
+ // SAFETY: OUTCOME_TRIGGERS enumerates every key in the returned trigger record.
110
+ const byTrigger = Object.fromEntries(
111
+ OUTCOME_TRIGGERS.map((value) => [value, 0])
112
+ ) as Record<OutcomeTrigger, number>;
113
+ // SAFETY: ADOPTIONS enumerates every key and each value gets a fresh stats object.
114
+ const validationByAdoption = Object.fromEntries(
115
+ ADOPTIONS.map((value) => [value, emptyValidationStats()])
116
+ ) as Record<OutcomeAdoption, OutcomeValidationStats>;
117
+ const adviceHashes = new Set<string>();
118
+ let first: { timestamp: string; time: number } | undefined;
119
+ let last: { timestamp: string; time: number } | undefined;
120
+
121
+ for (const record of records) {
122
+ adoption[record.adoption] += 1;
123
+ byTrigger[record.trigger] += 1;
124
+ adviceHashes.add(record.adviceHash);
125
+ const validation = validationByAdoption[record.adoption];
126
+ validation.total += 1;
127
+ validation[
128
+ record.validationStatus === "not-run" ? "notRun" : record.validationStatus
129
+ ] += 1;
130
+ const time = Date.parse(record.timestamp);
131
+ if (!first || time < first.time) {
132
+ first = { time, timestamp: record.timestamp };
133
+ }
134
+ if (!last || time > last.time) {
135
+ last = { time, timestamp: record.timestamp };
136
+ }
137
+ }
138
+ for (const value of Object.values(validationByAdoption)) {
139
+ const validated = value.passed + value.failed;
140
+ if (validated > 0) {
141
+ value.passRate = value.passed / validated;
142
+ }
143
+ }
144
+ return {
145
+ adoption,
146
+ byTrigger,
147
+ distinctAdvices: adviceHashes.size,
148
+ firstTimestamp: first?.timestamp,
149
+ lastTimestamp: last?.timestamp,
150
+ malformedLines,
151
+ total: records.length,
152
+ validationByAdoption,
153
+ };
154
+ };
package/src/outcomes.ts CHANGED
@@ -17,7 +17,7 @@ import { getAgentDir } from "@earendil-works/pi-coding-agent";
17
17
 
18
18
  export type OutcomeAdoption = "followed" | "not-followed" | "unknown";
19
19
  export type OutcomeValidation = "passed" | "failed" | "not-run" | "unknown";
20
- type OutcomeTrigger =
20
+ export type OutcomeTrigger =
21
21
  | "manual"
22
22
  | "executor-requested"
23
23
  | "turn-gate"
@@ -33,6 +33,12 @@ export const VALIDATIONS: OutcomeValidation[] = [
33
33
  "not-run",
34
34
  "unknown",
35
35
  ];
36
+ export const OUTCOME_TRIGGERS: OutcomeTrigger[] = [
37
+ "manual",
38
+ "executor-requested",
39
+ "turn-gate",
40
+ "repeated-tool-call",
41
+ ];
36
42
  export interface OutcomeRecord {
37
43
  adoption: OutcomeAdoption;
38
44
  adviceHash: string;
@@ -42,6 +48,7 @@ export interface OutcomeRecord {
42
48
  validationStatus: OutcomeValidation;
43
49
  }
44
50
  const MAX_LOG_BYTES = 1024 * 1024;
51
+ export const OUTCOME_LOG_MAX_BYTES = MAX_LOG_BYTES;
45
52
  const statePath = () => join(getAgentDir(), "advisor-outcomes-salt");
46
53
  export const outcomeLogPath = () =>
47
54
  join(getAgentDir(), "advisor-outcomes.jsonl");
@@ -1,36 +1,67 @@
1
1
  import { constants } from "node:fs";
2
2
  import { lstat, open, realpath } from "node:fs/promises";
3
- import { join, relative } from "node:path";
3
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
4
4
 
5
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
6
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
6
7
 
7
8
  import { redactAndCapText } from "./redaction.ts";
8
9
 
9
10
  const PREFERENCES_MAX_BYTES = 8 * 1024;
10
- // Keep the local filename components separate from Socket's URL-string heuristic.
11
+ // Keep local filename components separate from Socket's URL-string heuristic.
11
12
  const PREFERENCES_FILENAME = ["advisor-preferences", "md"].join(".");
13
+ const AGENTS_FILENAME = "AGENTS.md";
12
14
  export interface TextAttachment {
13
15
  bytes: number;
14
16
  text: string;
15
17
  }
16
18
 
19
+ export interface ProjectRulesAttachment {
20
+ bytes: number;
21
+ global?: TextAttachment;
22
+ project?: TextAttachment;
23
+ withheldReason?: "untrusted";
24
+ }
25
+
17
26
  const inside = (root: string, candidate: string) => {
18
27
  const path = relative(root, candidate);
19
- return path === "" || !(path.startsWith("..") || path.includes("../"));
28
+ return (
29
+ path === "" ||
30
+ (path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path))
31
+ );
20
32
  };
21
33
 
22
- /** Reads trusted project preferences without following a file or root escape. */
23
- export const readProjectPreferences = async (
24
- ctx: ExtensionContext,
25
- maxBytes = PREFERENCES_MAX_BYTES,
26
- redact = true
34
+ const hasSymlinkBelow = async (rootCandidate: string, candidate: string) => {
35
+ const root = resolve(rootCandidate);
36
+ const target = resolve(candidate);
37
+ if (!inside(root, target)) {
38
+ return true;
39
+ }
40
+ let current = root;
41
+ for (const part of relative(root, target).split(sep).filter(Boolean)) {
42
+ current = join(current, part);
43
+ const stats = await lstat(current);
44
+ if (stats.isSymbolicLink()) {
45
+ return true;
46
+ }
47
+ }
48
+ return false;
49
+ };
50
+
51
+ const readTrustedText = async (
52
+ candidate: string,
53
+ rootCandidate: string,
54
+ maxBytes: number,
55
+ redact: boolean
27
56
  ): Promise<TextAttachment | undefined> => {
28
- if (!ctx.isProjectTrusted()) {
57
+ if (maxBytes <= 0) {
29
58
  return;
30
59
  }
31
60
  try {
32
- const root = await realpath(ctx.cwd);
33
- const candidate = join(ctx.cwd, ".pi", PREFERENCES_FILENAME);
61
+ const root = await realpath(rootCandidate);
62
+ if (await hasSymlinkBelow(rootCandidate, candidate)) {
63
+ return;
64
+ }
34
65
  const stats = await lstat(candidate);
35
66
  if (stats.isSymbolicLink() || !stats.isFile()) {
36
67
  return;
@@ -39,6 +70,9 @@ export const readProjectPreferences = async (
39
70
  if (!inside(root, resolved)) {
40
71
  return;
41
72
  }
73
+ if (await hasSymlinkBelow(rootCandidate, candidate)) {
74
+ return;
75
+ }
42
76
  const flags = constants.O_NOFOLLOW
43
77
  ? constants.O_RDONLY + constants.O_NOFOLLOW
44
78
  : constants.O_RDONLY;
@@ -61,6 +95,50 @@ export const readProjectPreferences = async (
61
95
  await file.close();
62
96
  }
63
97
  } catch {
64
- // Missing, unreadable, or unsafe preferences are intentionally withheld.
98
+ // Missing, unreadable, or unsafe files are intentionally withheld.
99
+ }
100
+ };
101
+
102
+ export const readProjectPreferences = (
103
+ ctx: ExtensionContext,
104
+ maxBytes = PREFERENCES_MAX_BYTES,
105
+ redact = true
106
+ ): Promise<TextAttachment | undefined> => {
107
+ if (!ctx.isProjectTrusted()) {
108
+ return Promise.resolve(undefined);
109
+ }
110
+ return readTrustedText(
111
+ join(ctx.cwd, ".pi", PREFERENCES_FILENAME),
112
+ ctx.cwd,
113
+ maxBytes,
114
+ redact
115
+ );
116
+ };
117
+
118
+ export const readProjectRules = async (
119
+ ctx: ExtensionContext,
120
+ maxBytes = PREFERENCES_MAX_BYTES,
121
+ redact = true
122
+ ): Promise<ProjectRulesAttachment> => {
123
+ if (!ctx.isProjectTrusted()) {
124
+ return { bytes: 0, withheldReason: "untrusted" };
65
125
  }
126
+ const project = await readTrustedText(
127
+ join(ctx.cwd, AGENTS_FILENAME),
128
+ ctx.cwd,
129
+ maxBytes,
130
+ redact
131
+ );
132
+ const remaining = Math.max(0, maxBytes * 2 - (project?.bytes ?? 0));
133
+ const global = await readTrustedText(
134
+ join(getAgentDir(), AGENTS_FILENAME),
135
+ getAgentDir(),
136
+ Math.min(maxBytes, remaining),
137
+ redact
138
+ );
139
+ return {
140
+ bytes: (project?.bytes ?? 0) + (global?.bytes ?? 0),
141
+ global,
142
+ project,
143
+ };
66
144
  };
@@ -1,5 +1,10 @@
1
1
  import { isRecordOf, isString } from "./content-utils.ts";
2
2
  import type { JsonValue } from "./content-utils.ts";
3
+ import { AdvisorFollowUpCache } from "./follow-up.ts";
4
+ import type {
5
+ AdvisorFollowUpLookup,
6
+ AdvisorFollowUpPayload,
7
+ } from "./follow-up.ts";
3
8
  import { AdvisorJevLedgerState } from "./jev/ledger.ts";
4
9
  import type { AdvisorJevUsage } from "./jev/ledger.ts";
5
10
  import {
@@ -22,6 +27,7 @@ type ExecutionEffect = "continued" | "tool-blocked" | "session-blocked";
22
27
  export interface AdvisorInvocationRecord {
23
28
  cost?: number;
24
29
  decision?: GateDecision;
30
+ followUp?: boolean;
25
31
  executionEffect: ExecutionEffect;
26
32
  failure?: string;
27
33
  kind: "markdown" | "gate";
@@ -179,9 +185,14 @@ export class AdvisorSessionState {
179
185
  #consumedCalls = 0;
180
186
  #callReservations = new Set<string>();
181
187
  readonly #jev = new AdvisorJevLedgerState();
188
+ readonly #followUps: AdvisorFollowUpCache;
182
189
  #sessionTurnOrdinal = 0;
183
190
  #turnsSinceConsultation = 0;
184
191
 
192
+ constructor(now?: () => number) {
193
+ this.#followUps = new AdvisorFollowUpCache(now);
194
+ }
195
+
185
196
  resetTask() {
186
197
  this.#repetition = freshRepetition();
187
198
  this.#blockedReason = undefined;
@@ -190,6 +201,7 @@ export class AdvisorSessionState {
190
201
  this.#consumedCalls = 0;
191
202
  this.#callReservations.clear();
192
203
  this.#jev.reset();
204
+ this.#followUps.clear();
193
205
  this.#sessionTurnOrdinal = 0;
194
206
  this.#turnsSinceConsultation = 0;
195
207
  }
@@ -268,6 +280,26 @@ export class AdvisorSessionState {
268
280
  return this.#consumedCalls;
269
281
  }
270
282
 
283
+ advanceFollowUpToolCall() {
284
+ this.#followUps.advanceToolCall();
285
+ }
286
+
287
+ clearFollowUps() {
288
+ this.#followUps.clear();
289
+ }
290
+
291
+ followUpFor(adviceId: string): AdvisorFollowUpLookup {
292
+ return this.#followUps.get(adviceId);
293
+ }
294
+
295
+ captureFollowUp(
296
+ adviceId: string,
297
+ payload: AdvisorFollowUpPayload,
298
+ replacesAdviceId?: string
299
+ ) {
300
+ this.#followUps.capture(adviceId, payload, replacesAdviceId);
301
+ }
302
+
271
303
  /** Counts one completed executor turn on both turn counters. The ordinal
272
304
  * feeds the filter override window and summary positions and is never reset
273
305
  * by anything; turnsSinceConsultation resets on every consultation. */
@@ -495,6 +527,7 @@ export class AdvisorSessionState {
495
527
  `Budget: ${budget}`,
496
528
  `Usage: ${formatAdvisorUsageTotals(totals)}`,
497
529
  `Markdown advice: ${markdown.length} responses (${this.#ledger.draftConsultations} with drafts)`,
530
+ `Follow-ups: ${markdown.filter((item) => item.followUp).length}`,
498
531
  `Outcome reports: ${this.#ledger.outcomes}`,
499
532
  `Gate decisions: ${this.#decisionsLine()}`,
500
533
  `Loop matching: normalized tool signatures; ${this.#repetition.interventions} gate intervention${this.#repetition.interventions === 1 ? "" : "s"}`,
@@ -6,6 +6,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
6
6
  import { readTrackedFiles, readUntrackedFiles } from "../attachments.ts";
7
7
  import type { UntrackedAttachment } from "../attachments.ts";
8
8
  import {
9
+ advisorAgentsMdContextRef,
9
10
  advisorGitContextMaxCharsRef,
10
11
  advisorGitContextRef,
11
12
  advisorRedactSecretsRef,
@@ -23,7 +24,7 @@ import {
23
24
  ADVISOR_IMAGES_TOTAL_MAX_BYTES,
24
25
  selectedConversationImages,
25
26
  } from "../images.ts";
26
- import { readProjectPreferences } from "../preferences.ts";
27
+ import { readProjectPreferences, readProjectRules } from "../preferences.ts";
27
28
  import { redactAndCapText, redactSecrets } from "../redaction.ts";
28
29
  import { curateAdvisorConversation } from "../scout-curation.ts";
29
30
  import { runAdvisorScout } from "../scout.ts";
@@ -64,6 +65,8 @@ export interface ConsultationContext {
64
65
  supportsImages: boolean;
65
66
  /** Redacted project preferences, if present. */
66
67
  preferences?: { bytes: number; text: string };
68
+ /** Redacted trusted AGENTS.md files, or a withheld-project note. */
69
+ projectRules?: Awaited<ReturnType<typeof readProjectRules>>;
67
70
  /** Non-cancelling Scout outcome, when Scout ran. */
68
71
  scout?: Exclude<ScoutOutcome, { cancelled: true }>;
69
72
  /** Redacted tracked-file attachments. */
@@ -130,6 +133,13 @@ export const assembleConsultationContext = async (
130
133
  ATTACHMENT_TEXT_MAX_BYTES,
131
134
  advisorRedactSecretsRef
132
135
  );
136
+ const projectRules = advisorAgentsMdContextRef
137
+ ? await readProjectRules(
138
+ ctx,
139
+ ATTACHMENT_TEXT_MAX_BYTES,
140
+ advisorRedactSecretsRef
141
+ )
142
+ : undefined;
133
143
  const draftText = options.draft
134
144
  ? redactAndCapText(
135
145
  options.draft,
@@ -215,6 +225,7 @@ export const assembleConsultationContext = async (
215
225
  imagePartsSeen: census.imagePartsSeen,
216
226
  images,
217
227
  preferences,
228
+ projectRules,
218
229
  scout: curated.scout,
219
230
  supportsImages,
220
231
  tracked,