pi-warden 0.71.0 → 0.74.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 (51) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/adaptive.d.ts +3 -2
  3. package/dist/adaptive.js +4 -3
  4. package/dist/backend.d.ts +33 -14
  5. package/dist/backend.js +27 -14
  6. package/dist/config.d.ts +13 -8
  7. package/dist/config.js +20 -20
  8. package/dist/conscience.d.ts +1 -1
  9. package/dist/extension.d.ts +14 -3
  10. package/dist/extension.js +410 -64
  11. package/dist/guard.d.ts +2 -1
  12. package/dist/guard.js +4 -4
  13. package/dist/holds.d.ts +1 -1
  14. package/dist/holds.js +3 -2
  15. package/dist/host-compat.d.ts +2 -0
  16. package/dist/host-compat.js +132 -0
  17. package/dist/host-dirs.d.ts +10 -0
  18. package/dist/host-dirs.js +21 -0
  19. package/dist/index-cmd.d.ts +6 -5
  20. package/dist/index-cmd.js +16 -24
  21. package/dist/index.d.ts +12 -4
  22. package/dist/index.js +6 -2
  23. package/dist/init.js +31 -3
  24. package/dist/learning.d.ts +12 -11
  25. package/dist/learning.js +60 -38
  26. package/dist/loops.d.ts +2 -1
  27. package/dist/loops.js +3 -2
  28. package/dist/panel.d.ts +4 -1
  29. package/dist/panel.js +11 -6
  30. package/dist/prefs.d.ts +1 -1
  31. package/dist/prefs.js +3 -2
  32. package/dist/rules-audit.d.ts +208 -0
  33. package/dist/rules-audit.js +417 -0
  34. package/dist/rules-calibrate.d.ts +196 -0
  35. package/dist/rules-calibrate.js +474 -0
  36. package/dist/rules-lint.d.ts +2 -0
  37. package/dist/rules-lint.js +7 -2
  38. package/dist/rules-log.d.ts +6 -1
  39. package/dist/rules-log.js +9 -3
  40. package/dist/rules-report.d.ts +3 -0
  41. package/dist/rules-report.js +5 -0
  42. package/dist/rules.d.ts +72 -5
  43. package/dist/rules.js +116 -54
  44. package/dist/shape.js +8 -2
  45. package/dist/turn-rules.d.ts +86 -0
  46. package/dist/turn-rules.js +232 -0
  47. package/dist/widget-render.d.ts +23 -0
  48. package/dist/widget-render.js +82 -0
  49. package/dist/widget.d.ts +5 -22
  50. package/dist/widget.js +3 -80
  51. package/package.json +5 -3
package/dist/rules.d.ts CHANGED
@@ -9,10 +9,12 @@ import type { RulesConfig } from "./config.js";
9
9
  */
10
10
  /** How badly a rule matters; used only to order findings, never to decide whether a rule fires. */
11
11
  export type RuleSeverity = "high" | "normal" | "low";
12
+ /** When a rule is judged: `edit` on each write or edit (the default), `turn` once at the end of a run against the whole diff. */
13
+ export type RuleWhen = "edit" | "turn";
12
14
  export interface Rule {
13
15
  id: string;
14
16
  name: string;
15
- /** Rule text under the heading, fences included, `paths:`/`threshold:`/`severity:` lines removed. */
17
+ /** Rule text under the heading, fences included, `paths:`/`threshold:`/`severity:`/`source:` lines removed. */
16
18
  body: string;
17
19
  /** Globs the rule applies to; empty means every file. */
18
20
  paths: string[];
@@ -20,6 +22,13 @@ export interface Rule {
20
22
  threshold?: number;
21
23
  /** Ordering only; absent means `normal`. */
22
24
  severity?: RuleSeverity;
25
+ /** The `source: <file>:<line>` header: the instruction line the rule's wording came from. */
26
+ sourceRef?: {
27
+ file: string;
28
+ line: number;
29
+ };
30
+ /** `turn` rules are judged once per run against the whole diff, never per edit; absent means `edit`. */
31
+ when?: RuleWhen;
23
32
  /** Header lines that carried a bad value, reported by `/warden rules`; the line is dropped either way. */
24
33
  headerWarnings?: string[];
25
34
  /** Project-relative source file when the rule came from a resolved RuleSet. */
@@ -50,6 +59,11 @@ export type RulesSourceConfig = Pick<RulesConfig, "files" | "fallback">;
50
59
  export declare const FALLBACK_FILES: string[];
51
60
  /** TypeSafe answers at most 32 questions per request; one is kept for the edit locator. */
52
61
  export declare const MAX_RULES = 31;
62
+ export declare const EDIT_TEXT_LIMIT = 1500;
63
+ export declare const EDIT_CONTEXT_LINES = 20;
64
+ export declare const MAX_EDITS = 6;
65
+ export declare const RULE_BODY_LIMIT = 400;
66
+ export declare const STEER_BODY_LIMIT = 200;
53
67
  /**
54
68
  * A fallback document cut to `limit` characters while keeping its shape: every heading stays and each section keeps its
55
69
  * head, so a rule stated in the last section of a very long AGENTS.md is still seen. Code inside fences is treated as text.
@@ -84,6 +98,10 @@ export declare class RuleStore {
84
98
  }
85
99
  /** Rules that apply to a path: unscoped rules plus those whose `paths` match. */
86
100
  export declare function rulesFor(set: RuleSet, path: string): Rule[];
101
+ /** Rules asked about a write or edit: those that apply to the path without a `when: turn` header. */
102
+ export declare function editRulesFor(set: RuleSet, path: string): Rule[];
103
+ /** Rules judged once per run against the whole diff: those with a `when: turn` header. */
104
+ export declare function turnRulesFor(set: RuleSet): Rule[];
87
105
  export declare function describeRuleSet(set: RuleSet | undefined): string;
88
106
  /** Detailed local-only view for `/warden rules`; nothing here is sent to Jev. */
89
107
  export declare function formatRuleSetDetails(set: RuleSet | undefined, tier: RulesTier, exclude?: readonly string[]): string;
@@ -107,10 +125,11 @@ export interface RulesTarget {
107
125
  /** The redacted content of a write or edit as Jev sees it, or undefined when the call carries nothing to judge. */
108
126
  export declare function describeTarget(tool: string, input: Record<string, unknown>, cwd: string): RulesTarget | undefined;
109
127
  export type RuleOutcome = "compliant" | "violation" | "not_applicable" | "insufficient_context";
128
+ export declare const OUTCOMES: Record<RuleOutcome, string>;
110
129
  export declare const AGGREGATE_QUESTION = "rules";
111
130
  export declare const LOCATOR_QUESTION = "which_edit";
112
131
  export declare function ruleQuestion(rule: Rule): import("pi-typesafe").ChoiceQuestion<Record<RuleOutcome, string>>;
113
- /** The rules asked about for a write: scoped to its path first, then cut to the request cap in file order. */
132
+ /** The rules asked about for a write: scoped to its path first, `when: turn` rules excluded, then cut to the request cap in file order. */
114
133
  export declare function buildRulesRequest(target: RulesTarget, set: RuleSet): {
115
134
  firstDropped?: string;
116
135
  state: {
@@ -138,14 +157,38 @@ export interface RuleScore {
138
157
  threshold?: number;
139
158
  /** The rule's severity when it set one; absent means `normal`. */
140
159
  severity?: RuleSeverity;
160
+ /** The instruction line the rule's wording came from when its `source:` header set one. */
161
+ sourceRef?: {
162
+ file: string;
163
+ line: number;
164
+ };
141
165
  }
142
166
  export interface RuleFinding extends RuleScore {
143
167
  body: string;
144
168
  }
169
+ /** One answer of the judge as the scoring reads it: the choice it picked and the probabilities behind it. */
170
+ export type RuleAnswer = {
171
+ type: string;
172
+ choice?: string;
173
+ probabilities?: Record<string, number>;
174
+ } | undefined;
175
+ export interface RuleScoring {
176
+ scores: RuleScore[];
177
+ /** Violations at or above the rule's cutoff, severity first then strongest. */
178
+ findings: RuleFinding[];
179
+ /** Scores at or above `rules.softThreshold` but below the rule's cutoff. */
180
+ softFindings: RuleFinding[];
181
+ }
182
+ /**
183
+ * Score the judge's answers rule by rule: one score each, a finding at the rule's own cutoff (or `rules.threshold` when
184
+ * the rule sets none), and a soft finding between `rules.softThreshold` and that cutoff. An aggregate document is one
185
+ * rule under its source's name. Shared by the live guard and history replays, so both respect the same cutoffs.
186
+ */
187
+ export declare function scoreRuleAnswers(applicable: readonly Rule[], answers: Record<string, RuleAnswer>, config: RulesConfig, aggregateName?: string): RuleScoring;
145
188
  export interface RulesVerdict {
146
189
  source: "skipped" | "typesafe" | "error";
147
190
  path: string;
148
- tool: "write" | "edit";
191
+ tool: "write" | "edit" | "turn";
149
192
  sources: string[];
150
193
  /** Rules asked about, after path scoping and the cap; 1 for an aggregate document. */
151
194
  asked: number;
@@ -175,11 +218,35 @@ export interface RulesOptions {
175
218
  timeoutMs: number;
176
219
  signal?: AbortSignal | undefined;
177
220
  }
178
- /** Check whether a project-relative path is ignored by the project's gitignore rules. Uses `git check-ignore -q` run from `cwd`. Returns false when git is unavailable or the path cannot be checked. */
179
- export declare function gitIgnored(projectRel: string, cwd: string): boolean;
221
+ /** Check whether a project-relative path is ignored by the project's gitignore rules. Uses `git check-ignore -q` run from `cwd` (with `--no-index`, so a force-added ignored file still counts as ignored). Returns false when git is unavailable or the path cannot be checked. */
222
+ export declare function gitIgnored(projectRel: string, cwd: string, noIndex?: boolean): boolean;
180
223
  /** Why a write or edit is not judged against the rules: nothing to judge, no rules, or a path the config keeps out. */
181
224
  export declare function skipReason(target: RulesTarget | undefined, set: RuleSet | undefined, config: RulesConfig): string | undefined;
225
+ /** One request's answer payload as Jev returns it. */
226
+ /** One question in a rules request: its key, the rule it came from, and the body quoted in a steer. */
227
+ export interface RuleScoreEntry {
228
+ key: string;
229
+ id: string;
230
+ name: string;
231
+ body: string;
232
+ /** The rule's own cutoff, severity, and source citation when it set them. */
233
+ rule?: Pick<Rule, "threshold" | "severity" | "sourceRef">;
234
+ }
235
+ /**
236
+ * Scores and findings for one request's answers: a finding needs the rule's own cutoff when it set one, else the global
237
+ * threshold, and the soft tier sits under that cutoff. Findings are ordered severity first, then the strongest score.
238
+ */
239
+ export declare function scoreAnswers(answers: Record<string, RuleAnswer | undefined>, entries: readonly RuleScoreEntry[], config: RulesConfig): {
240
+ scores: RuleScore[];
241
+ findings: RuleFinding[];
242
+ softFindings: RuleFinding[];
243
+ };
182
244
  export declare function evaluateRules(tool: string, input: Record<string, unknown>, options: RulesOptions): Promise<RulesVerdict>;
245
+ /**
246
+ * The judgment half of `evaluateRules` for a prepared target: skip reasons, one request, scores, findings. A caller that
247
+ * builds its own target (the end-of-run pass judges a file's diff as one edit) gets the same question and scoring.
248
+ */
249
+ export declare function evaluateRulesTarget(target: RulesTarget | undefined, tool: string, shownPath: string, options: RulesOptions): Promise<RulesVerdict>;
183
250
  /** Names each violated rule with a short quote of its text; the third hit of one rule in a session makes it a standing rule. */
184
251
  export declare function rulesSteer(verdict: RulesVerdict, counts: ReadonlyMap<string, number>): string;
185
252
  export declare function formatRules(verdict: RulesVerdict, template?: string): string;
package/dist/rules.js CHANGED
@@ -12,11 +12,11 @@ export const FALLBACK_FILES = ["AGENTS.md", "CLAUDE.md", "README.md"];
12
12
  /** TypeSafe answers at most 32 questions per request; one is kept for the edit locator. */
13
13
  export const MAX_RULES = 31;
14
14
  const CONTENT_LIMIT = 6000;
15
- const EDIT_TEXT_LIMIT = 1500;
16
- const EDIT_CONTEXT_LINES = 20;
17
- const MAX_EDITS = 6;
18
- const RULE_BODY_LIMIT = 400;
19
- const STEER_BODY_LIMIT = 200;
15
+ export const EDIT_TEXT_LIMIT = 1500;
16
+ export const EDIT_CONTEXT_LINES = 20;
17
+ export const MAX_EDITS = 6;
18
+ export const RULE_BODY_LIMIT = 400;
19
+ export const STEER_BODY_LIMIT = 200;
20
20
  const ID_LIMIT = 64;
21
21
  const RULE_SHAPE_CONTEXT_LINES = 15;
22
22
  const MODAL_WORDS = /^(?:must|shall|never|always|do not|don't|cannot|can't|avoid|prefer|require|should not|shouldn't|no |not )/i;
@@ -28,7 +28,10 @@ const HEADING = /^(#{1,6})\s+(.+?)\s*#*\s*$/;
28
28
  const PATHS_LINE = /^\s*(?:paths?|applies to|files?)\s*:\s*(.+?)\s*$/i;
29
29
  const THRESHOLD_LINE = /^\s*threshold\s*:\s*(.+?)\s*$/i;
30
30
  const SEVERITY_LINE = /^\s*severity\s*:\s*(.+?)\s*$/i;
31
+ const SOURCE_REF_LINE = /^\s*source\s*:\s*(.+?)\s*$/i;
32
+ const WHEN_LINE = /^\s*when\s*:\s*(.+?)\s*$/i;
31
33
  const SEVERITIES = ["high", "normal", "low"];
34
+ const WHENS = ["edit", "turn"];
32
35
  function slug(name, used) {
33
36
  const full = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
34
37
  // Cut at a word boundary so a long heading keeps whole words in its id.
@@ -109,10 +112,14 @@ export function parseRules(markdown) {
109
112
  let paths = [];
110
113
  let threshold;
111
114
  let severity;
115
+ let sourceRef;
116
+ let when;
112
117
  const headerWarnings = [];
113
118
  let seenPaths = false;
114
119
  let seenThreshold = false;
115
120
  let seenSeverity = false;
121
+ let seenSource = false;
122
+ let seenWhen = false;
116
123
  // Header lines may sit in any order and at most once. A bad value is dropped with a warning; the line never stays in the body.
117
124
  for (;;) {
118
125
  const first = lines.findIndex(text => text.trim());
@@ -159,12 +166,46 @@ export function parseRules(markdown) {
159
166
  }
160
167
  continue;
161
168
  }
169
+ // One small block for the `source: <file>:<line>` citation header, beside the other header lines.
170
+ const cited = SOURCE_REF_LINE.exec(line);
171
+ if (cited) {
172
+ lines.splice(first, 1);
173
+ const raw = cited[1].replace(/^`|`$/g, "").trim();
174
+ const ref = /^(.+):(\d+)$/.exec(raw);
175
+ const file = ref?.[1]?.trim();
176
+ const at = ref ? Number(ref[2]) : 0;
177
+ if (seenSource)
178
+ headerWarnings.push("source: appears more than once; the first is kept");
179
+ else if (!file || !at)
180
+ headerWarnings.push(`source: ${raw || "(empty)"} is not a file:line reference; ignored`);
181
+ else {
182
+ sourceRef = { file, line: at };
183
+ seenSource = true;
184
+ }
185
+ continue;
186
+ }
187
+ const timing = WHEN_LINE.exec(line);
188
+ if (timing) {
189
+ lines.splice(first, 1);
190
+ const raw = timing[1].trim().toLowerCase();
191
+ if (seenWhen)
192
+ headerWarnings.push("when: appears more than once; the first is kept");
193
+ else if (!WHENS.includes(raw))
194
+ headerWarnings.push(`when: ${timing[1].trim() || "(empty)"} is not edit or turn; ignored`);
195
+ else {
196
+ when = raw;
197
+ seenWhen = true;
198
+ }
199
+ continue;
200
+ }
162
201
  break;
163
202
  }
164
203
  return {
165
204
  id: slug(draft.name, used), name: draft.name.trim(), body: lines.join("\n").trim(), paths,
166
205
  ...(threshold === undefined ? {} : { threshold }),
167
206
  ...(severity === undefined ? {} : { severity }),
207
+ ...(sourceRef === undefined ? {} : { sourceRef }),
208
+ ...(when === undefined ? {} : { when }),
168
209
  ...(headerWarnings.length ? { headerWarnings } : {}),
169
210
  };
170
211
  });
@@ -321,6 +362,14 @@ function ruleSet(sources, texts, maxChars) {
321
362
  export function rulesFor(set, path) {
322
363
  return set.rules.filter(rule => !rule.paths.length || matchGlob(path, rule.paths) !== undefined);
323
364
  }
365
+ /** Rules asked about a write or edit: those that apply to the path without a `when: turn` header. */
366
+ export function editRulesFor(set, path) {
367
+ return rulesFor(set, path).filter(rule => rule.when !== "turn");
368
+ }
369
+ /** Rules judged once per run against the whole diff: those with a `when: turn` header. */
370
+ export function turnRulesFor(set) {
371
+ return set.rules.filter(rule => rule.when === "turn");
372
+ }
324
373
  export function describeRuleSet(set) {
325
374
  if (!set)
326
375
  return "none found";
@@ -350,6 +399,8 @@ export function formatRuleSetDetails(set, tier, exclude = []) {
350
399
  `paths: ${rule.paths.length ? rule.paths.join(", ") : "(all)"}`,
351
400
  ...(rule.threshold === undefined ? [] : [`threshold: ${rule.threshold}`]),
352
401
  ...(rule.severity === undefined ? [] : [`severity: ${rule.severity}`]),
402
+ ...(rule.sourceRef === undefined ? [] : [`source: ${rule.sourceRef.file}:${rule.sourceRef.line}`]),
403
+ ...(rule.when === undefined || rule.when === "edit" ? [] : [`when: ${rule.when}`]),
353
404
  ].join(", ");
354
405
  lines.push(`${index + 1}. ${rule.id}${from} ${settings}`);
355
406
  if (rule.headerWarnings?.length)
@@ -431,7 +482,7 @@ export function describeTarget(tool, input, cwd) {
431
482
  const more = input.edits.length - MAX_EDITS;
432
483
  return { tool, path, edits, ...(more > 0 ? { moreEdits: more } : {}) };
433
484
  }
434
- const OUTCOMES = {
485
+ export const OUTCOMES = {
435
486
  compliant: "The change follows this rule, or leaves an earlier violation as it was.",
436
487
  violation: "The change introduces a violation of this rule.",
437
488
  not_applicable: "This rule does not concern the kind of content written: another language, file type, or subject.",
@@ -443,9 +494,9 @@ export const LOCATOR_QUESTION = "which_edit";
443
494
  export function ruleQuestion(rule) {
444
495
  return choice(`${FRAME}\nRule: ${rule.name}\n${rule.body ? clip(rule.body, RULE_BODY_LIMIT) : "(no further detail beyond the heading)"}`, OUTCOMES);
445
496
  }
446
- /** The rules asked about for a write: scoped to its path first, then cut to the request cap in file order. */
497
+ /** The rules asked about for a write: scoped to its path first, `when: turn` rules excluded, then cut to the request cap in file order. */
447
498
  export function buildRulesRequest(target, set) {
448
- const scoped = rulesFor(set, target.path);
499
+ const scoped = editRulesFor(set, target.path);
449
500
  const applicable = scoped.slice(0, MAX_RULES);
450
501
  const dropped = scoped.length - applicable.length;
451
502
  const questions = {};
@@ -476,10 +527,21 @@ export function buildRulesRequest(target, set) {
476
527
  ...(dropped ? { firstDropped: scoped[MAX_RULES].id } : {}),
477
528
  };
478
529
  }
479
- /** Check whether a project-relative path is ignored by the project's gitignore rules. Uses `git check-ignore -q` run from `cwd`. Returns false when git is unavailable or the path cannot be checked. */
480
- export function gitIgnored(projectRel, cwd) {
530
+ /**
531
+ * Score the judge's answers rule by rule: one score each, a finding at the rule's own cutoff (or `rules.threshold` when
532
+ * the rule sets none), and a soft finding between `rules.softThreshold` and that cutoff. An aggregate document is one
533
+ * rule under its source's name. Shared by the live guard and history replays, so both respect the same cutoffs.
534
+ */
535
+ export function scoreRuleAnswers(applicable, answers, config, aggregateName) {
536
+ const entries = aggregateName !== undefined
537
+ ? [{ key: AGGREGATE_QUESTION, id: AGGREGATE_QUESTION, name: aggregateName, body: "" }]
538
+ : applicable.map(rule => ({ key: `rule_${rule.id}`, id: rule.id, name: rule.name, body: rule.body, rule }));
539
+ return scoreAnswers(answers, entries, config);
540
+ }
541
+ /** Check whether a project-relative path is ignored by the project's gitignore rules. Uses `git check-ignore -q` run from `cwd` (with `--no-index`, so a force-added ignored file still counts as ignored). Returns false when git is unavailable or the path cannot be checked. */
542
+ export function gitIgnored(projectRel, cwd, noIndex = false) {
481
543
  try {
482
- execFileSync("git", ["check-ignore", "-q", projectRel], { cwd, timeout: 2000, stdio: "pipe" });
544
+ execFileSync("git", ["check-ignore", ...(noIndex ? ["--no-index"] : []), "-q", projectRel], { cwd, timeout: 2000, stdio: "pipe" });
483
545
  return true;
484
546
  }
485
547
  catch {
@@ -500,16 +562,54 @@ export function skipReason(target, set, config) {
500
562
  return `rules do not apply by rules.skip (${skipped})`;
501
563
  if (set.proseOnly)
502
564
  return `no rules found in ${set.sources.join(", ")} (prose only)`;
503
- if (set.rules.length && !rulesFor(set, target.path).length)
565
+ if (set.rules.length && !editRulesFor(set, target.path).length)
504
566
  return "no rule's paths match this file";
505
567
  return undefined;
506
568
  }
507
569
  function skipped(target, tool, path, set, reason) {
508
570
  return { source: "skipped", tool: target?.tool ?? (tool === "edit" ? "edit" : "write"), path, sources: set?.sources ?? [], asked: 0, aggregate: false, findings: [], skippedReason: reason };
509
571
  }
572
+ /**
573
+ * Scores and findings for one request's answers: a finding needs the rule's own cutoff when it set one, else the global
574
+ * threshold, and the soft tier sits under that cutoff. Findings are ordered severity first, then the strongest score.
575
+ */
576
+ export function scoreAnswers(answers, entries, config) {
577
+ const scores = [];
578
+ const findings = [];
579
+ const softFindings = [];
580
+ const softThreshold = config.softThreshold ?? 0;
581
+ for (const entry of entries) {
582
+ const answer = answers[entry.key];
583
+ if (!answer || typeof answer.choice !== "string")
584
+ continue;
585
+ const violation = answer.probabilities?.violation ?? (answer.choice === "violation" ? 1 : 0);
586
+ const outcome = (answer.choice in OUTCOMES ? answer.choice : "insufficient_context");
587
+ const score = {
588
+ id: entry.id, name: entry.name, outcome, violation,
589
+ ...(entry.rule?.threshold === undefined ? {} : { threshold: entry.rule.threshold }),
590
+ ...(entry.rule?.severity === undefined ? {} : { severity: entry.rule.severity }),
591
+ ...(entry.rule?.sourceRef === undefined ? {} : { sourceRef: entry.rule.sourceRef }),
592
+ };
593
+ const cutoff = entry.rule?.threshold ?? config.threshold;
594
+ scores.push(score);
595
+ if (score.violation >= cutoff)
596
+ findings.push({ ...score, body: entry.body });
597
+ else if (softThreshold > 0 && score.violation >= softThreshold)
598
+ softFindings.push({ ...score, body: entry.body });
599
+ }
600
+ findings.sort(bySeverityThenScore);
601
+ softFindings.sort(bySeverityThenScore);
602
+ return { scores, findings, softFindings };
603
+ }
510
604
  export async function evaluateRules(tool, input, options) {
511
605
  const target = describeTarget(tool, input, options.cwd);
512
- const shownPath = target?.path ?? (typeof input.path === "string" ? input.path : tool);
606
+ return evaluateRulesTarget(target, tool, typeof input.path === "string" ? input.path : tool, options);
607
+ }
608
+ /**
609
+ * The judgment half of `evaluateRules` for a prepared target: skip reasons, one request, scores, findings. A caller that
610
+ * builds its own target (the end-of-run pass judges a file's diff as one edit) gets the same question and scoring.
611
+ */
612
+ export async function evaluateRulesTarget(target, tool, shownPath, options) {
513
613
  const { set } = options;
514
614
  const reason = skipReason(target, set, options.config);
515
615
  if (reason || !target || !set)
@@ -523,46 +623,7 @@ export async function evaluateRules(tool, input, options) {
523
623
  if (!result.ok)
524
624
  return { source: "error", ...base, findings: [], error: result.error, ...(result.errorCode ? { errorCode: result.errorCode } : {}) };
525
625
  const answers = result.answers;
526
- const read = (key, id, name) => {
527
- const answer = answers[key];
528
- if (!answer || typeof answer.choice !== "string")
529
- return undefined;
530
- const violation = answer.probabilities?.violation ?? (answer.choice === "violation" ? 1 : 0);
531
- const outcome = (answer.choice in OUTCOMES ? answer.choice : "insufficient_context");
532
- return { id, name, outcome, violation };
533
- };
534
- const scores = [];
535
- const findings = [];
536
- const softFindings = [];
537
- const softThreshold = options.config.softThreshold ?? 0;
538
- // A finding needs the rule's own cutoff when it set one, else the global threshold. The soft tier sits under that cutoff.
539
- const scoreRule = (score, rule, body) => {
540
- const cutoff = rule?.threshold ?? options.config.threshold;
541
- const scored = {
542
- ...score,
543
- ...(rule?.threshold === undefined ? {} : { threshold: rule.threshold }),
544
- ...(rule?.severity === undefined ? {} : { severity: rule.severity }),
545
- };
546
- scores.push(scored);
547
- if (scored.violation >= cutoff)
548
- findings.push({ ...scored, body });
549
- else if (softThreshold > 0 && scored.violation >= softThreshold)
550
- softFindings.push({ ...scored, body });
551
- };
552
- if (aggregate) {
553
- const score = read(AGGREGATE_QUESTION, AGGREGATE_QUESTION, `the project's ${set.sources[0]}`);
554
- if (score)
555
- scoreRule(score, undefined, "");
556
- }
557
- else {
558
- for (const rule of request.applicable) {
559
- const score = read(`rule_${rule.id}`, rule.id, rule.name);
560
- if (score)
561
- scoreRule(score, rule, rule.body);
562
- }
563
- }
564
- findings.sort(bySeverityThenScore);
565
- softFindings.sort(bySeverityThenScore);
626
+ const { scores, findings, softFindings } = scoreRuleAnswers(request.applicable, answers, options.config, aggregate ? `the project's ${set.sources[0]}` : undefined);
566
627
  const verdict = { source: "typesafe", ...base, scores, findings, ...(softFindings.length ? { softFindings } : {}), model: result.model, elapsedMs: result.elapsedMs };
567
628
  const locator = answers[LOCATOR_QUESTION];
568
629
  if (findings.length && typeof locator?.choice === "string") {
@@ -590,7 +651,8 @@ export function rulesSteer(verdict, counts) {
590
651
  const count = counts.get(finding.id) ?? 0;
591
652
  const repeat = count >= 3 ? `; ${count}${count === 3 ? "rd" : "th"} time this session` : "";
592
653
  const body = finding.body ? `: ${clip(finding.body.replace(/\s+/g, " ").trim(), STEER_BODY_LIMIT).replace(/[.;:,]+$/, "")}` : "";
593
- return `"${finding.name}" (${finding.violation.toFixed(2)}${repeat})${body}`;
654
+ const from = finding.sourceRef ? ` (from ${finding.sourceRef.file} line ${finding.sourceRef.line})` : "";
655
+ return `"${finding.name}"${from} (${finding.violation.toFixed(2)}${repeat})${body}`;
594
656
  }).join("; ");
595
657
  // The steer names the rule and the written file only: a named rules or config file sends a weak model off to read it.
596
658
  const what = verdict.aggregate ? "breaks a project rule" : `violates project rule${verdict.findings.length === 1 ? "" : "s"}`;
package/dist/shape.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { isMode } from "./config.js";
2
- import { resolveBackend } from "./backend.js";
2
+ import { resolveJudgmentBackend } from "./backend.js";
3
3
  import { redact } from "./redact.js";
4
4
  import { DEFAULT_TEMPLATES } from "./widget.js";
5
5
  /** The config layout this extension build expects; compared with the loaded config module's CONFIG_SCHEMA. */
@@ -8,6 +8,10 @@ const off = { enabled: false };
8
8
  // Duplicated from defaultConfig(): this file must work when the config module is stale and lacks the key.
9
9
  const coreTools = () => ["read", "bash", "edit", "write", "grep", "find", "ls"];
10
10
  const proseOff = () => ({ ...off, audience: "technical", threshold: 1, trend: 3, minChars: 1 });
11
+ // A stale config module hands back either its own resolution or a raw value; both end up resolved here.
12
+ const resolveSetting = (source) => source.backendRefusal !== undefined
13
+ ? { typesafeBackend: source.typesafeBackend, backendRefusal: source.backendRefusal }
14
+ : resolveJudgmentBackend(source.typesafeBackend);
11
15
  /**
12
16
  * `loadConfig()` always returns a complete object, yet live sessions crashed at `config.slop.prose.enabled` after a package
13
17
  * update. A partially updated module graph (extension from one version, config from another) is the only known way to get
@@ -23,10 +27,12 @@ export function completeConfig(loaded) {
23
27
  missing.push(key);
24
28
  return fallback;
25
29
  };
30
+ const backend = resolveSetting(source);
26
31
  const config = {
27
32
  enabled: source.enabled ?? true,
28
33
  typesafe: source.typesafe ?? false,
29
- typesafeBackend: resolveBackend(source.typesafeBackend),
34
+ typesafeBackend: backend.typesafeBackend,
35
+ backendRefusal: backend.backendRefusal,
30
36
  mode: isMode(source.mode) ? source.mode : "steer",
31
37
  timeoutMs: source.timeoutMs ?? 5000,
32
38
  maxRequests: source.maxRequests ?? 500,
@@ -0,0 +1,86 @@
1
+ import type { Judge } from "pi-typesafe";
2
+ import type { RulesConfig } from "./config.js";
3
+ import type { Rule, RulesVerdict, RuleSet } from "./rules.js";
4
+ import type { ShellSkip } from "./shell-writes.js";
5
+ /** The verdict path for a judgment about the whole run; the rules log keys clears by path, so it must not look like a file. */
6
+ export declare const TURN_PATH = "(whole turn)";
7
+ export declare const TURN_QUESTION_PREFIX = "turn_";
8
+ /** The files judged per shell command and per run; beyond the cap the files are named in the trace and left out. */
9
+ export declare const SHELL_RULES_CHECKS = 5;
10
+ export type SnapshotResult = {
11
+ tree: string;
12
+ reason?: undefined;
13
+ } | {
14
+ tree?: undefined;
15
+ reason: string;
16
+ };
17
+ /** The run's baseline; `reason` says why turn rules are skipped this run and earns one trace line in the caller. */
18
+ export declare function snapshotTree(cwd: string): Promise<SnapshotResult>;
19
+ export interface TurnFileDiff {
20
+ /** Project-relative path with forward slashes. */
21
+ path: string;
22
+ /** `M`, `A`, or `D`, as git reports it between the two trees. */
23
+ status: string;
24
+ /** This file's unified diff, capped at `maxChars` with an inline note when cut. */
25
+ diff: string;
26
+ }
27
+ export interface TurnDiff {
28
+ files: TurnFileDiff[];
29
+ /** The whole change for the turn question: every file's diff joined, capped at `maxChars` in total. */
30
+ text: string;
31
+ /** What the caps cut, named per file, so the request and the trace say what the judge does not see. */
32
+ cuts: string[];
33
+ }
34
+ /**
35
+ * The working-tree change since the snapshot: the files git reports and each one's unified diff, capped per file and
36
+ * in total at `maxChars` (the `rules.maxChars` cap), each cut named. Untracked non-ignored files are in both trees, so
37
+ * they appear here like any other change. Undefined when the end-of-run snapshot fails.
38
+ */
39
+ export declare function diffSince(cwd: string, startTree: string, maxChars: number): Promise<TurnDiff | undefined>;
40
+ export declare function turnRuleQuestion(rule: Rule): import("pi-typesafe").ChoiceQuestion<Record<import("./rules.js").RuleOutcome, string>>;
41
+ export declare function buildTurnRequest(task: string, diff: string, rules: readonly Rule[], cuts?: readonly string[]): {
42
+ state: {
43
+ truncated?: string;
44
+ task: string;
45
+ diff: string;
46
+ };
47
+ questions: Record<string, import("pi-typesafe").ChoiceQuestion<import("@typesafe-ai/sdk").ChoiceCriteria>>;
48
+ };
49
+ export interface TurnRulesOptions {
50
+ judge: Judge;
51
+ config: RulesConfig;
52
+ timeoutMs: number;
53
+ signal?: AbortSignal | undefined;
54
+ sources: readonly string[];
55
+ }
56
+ /** One request that judges the turn rules against the whole diff and the task. */
57
+ export declare function evaluateTurnRules(task: string, diff: string, rules: readonly Rule[], options: TurnRulesOptions): Promise<RulesVerdict>;
58
+ export interface TurnRunOptions {
59
+ cwd: string;
60
+ config: RulesConfig;
61
+ set: RuleSet | undefined;
62
+ judge?: Judge | undefined;
63
+ timeoutMs: number;
64
+ signal?: AbortSignal | undefined;
65
+ /** The user's request for this run. */
66
+ task?: string | undefined;
67
+ /** The snapshot tree taken at the start of the run. */
68
+ startTree: string;
69
+ /** Project-relative paths a `write`, `edit`, or literal shell write already judged during this run. */
70
+ alreadyJudged: ReadonlySet<string>;
71
+ }
72
+ export interface TurnRunResult {
73
+ /** The turn verdict first (when turn rules exist), then one per file a per-edit judgment never saw. */
74
+ verdicts: RulesVerdict[];
75
+ /** What the caps cut from the diff, named per file. */
76
+ cuts: string[];
77
+ /** Files the per-run request cap left unjudged, named for the trace like the shell path's skips. */
78
+ skips: ShellSkip[];
79
+ /** Why nothing was judged, when the pass was skipped; absent when there was nothing to judge or it ran. */
80
+ skipped?: string;
81
+ }
82
+ /** Which files of the diff no per-edit judgment saw: in-place and generated changes, and subagent writes. */
83
+ export declare function unjudgedFiles(diff: TurnDiff, alreadyJudged: ReadonlySet<string>): TurnFileDiff[];
84
+ export declare function evaluateTurnRun(options: TurnRunOptions): Promise<TurnRunResult>;
85
+ /** Names each violated rule like the per-edit steer; one message covers the turn verdict and the missed files. */
86
+ export declare function turnSteer(verdicts: readonly RulesVerdict[], counts: ReadonlyMap<string, number>): string | undefined;