@cad0p/pi-steering 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/LICENSE +29 -0
- package/README.md +920 -0
- package/dist/__test-helpers__.d.ts +95 -0
- package/dist/__test-helpers__.d.ts.map +1 -0
- package/dist/__test-helpers__.js +174 -0
- package/dist/__test-helpers__.js.map +1 -0
- package/dist/bin/pi-steering.d.ts +9 -0
- package/dist/bin/pi-steering.d.ts.map +1 -0
- package/dist/bin/pi-steering.js +506 -0
- package/dist/bin/pi-steering.js.map +1 -0
- package/dist/compat.d.ts +50 -0
- package/dist/compat.d.ts.map +1 -0
- package/dist/compat.js +180 -0
- package/dist/compat.js.map +1 -0
- package/dist/defaults.d.ts +61 -0
- package/dist/defaults.d.ts.map +1 -0
- package/dist/defaults.js +106 -0
- package/dist/defaults.js.map +1 -0
- package/dist/define-config.d.ts +235 -0
- package/dist/define-config.d.ts.map +1 -0
- package/dist/define-config.js +118 -0
- package/dist/define-config.js.map +1 -0
- package/dist/define-predicate.d.ts +49 -0
- package/dist/define-predicate.d.ts.map +1 -0
- package/dist/define-predicate.js +35 -0
- package/dist/define-predicate.js.map +1 -0
- package/dist/evaluator-internals/context.d.ts +186 -0
- package/dist/evaluator-internals/context.d.ts.map +1 -0
- package/dist/evaluator-internals/context.js +219 -0
- package/dist/evaluator-internals/context.js.map +1 -0
- package/dist/evaluator-internals/override.d.ts +19 -0
- package/dist/evaluator-internals/override.d.ts.map +1 -0
- package/dist/evaluator-internals/override.js +79 -0
- package/dist/evaluator-internals/override.js.map +1 -0
- package/dist/evaluator-internals/predicates.d.ts +152 -0
- package/dist/evaluator-internals/predicates.d.ts.map +1 -0
- package/dist/evaluator-internals/predicates.js +849 -0
- package/dist/evaluator-internals/predicates.js.map +1 -0
- package/dist/evaluator-internals/speculative-synthesis.d.ts +102 -0
- package/dist/evaluator-internals/speculative-synthesis.d.ts.map +1 -0
- package/dist/evaluator-internals/speculative-synthesis.js +101 -0
- package/dist/evaluator-internals/speculative-synthesis.js.map +1 -0
- package/dist/evaluator.d.ts +101 -0
- package/dist/evaluator.d.ts.map +1 -0
- package/dist/evaluator.js +714 -0
- package/dist/evaluator.js.map +1 -0
- package/dist/helpers/walker-unknown-cwd-reason.d.ts +68 -0
- package/dist/helpers/walker-unknown-cwd-reason.d.ts.map +1 -0
- package/dist/helpers/walker-unknown-cwd-reason.js +73 -0
- package/dist/helpers/walker-unknown-cwd-reason.js.map +1 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +93 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/drop-unused-observers.d.ts +43 -0
- package/dist/internal/drop-unused-observers.d.ts.map +1 -0
- package/dist/internal/drop-unused-observers.js +50 -0
- package/dist/internal/drop-unused-observers.js.map +1 -0
- package/dist/internal/finalize-plugin-state.d.ts +22 -0
- package/dist/internal/finalize-plugin-state.d.ts.map +1 -0
- package/dist/internal/finalize-plugin-state.js +14 -0
- package/dist/internal/finalize-plugin-state.js.map +1 -0
- package/dist/internal/merge-observers.d.ts +30 -0
- package/dist/internal/merge-observers.d.ts.map +1 -0
- package/dist/internal/merge-observers.js +27 -0
- package/dist/internal/merge-observers.js.map +1 -0
- package/dist/internal/pattern-utils.d.ts +33 -0
- package/dist/internal/pattern-utils.d.ts.map +1 -0
- package/dist/internal/pattern-utils.js +20 -0
- package/dist/internal/pattern-utils.js.map +1 -0
- package/dist/internal/ref-text.d.ts +11 -0
- package/dist/internal/ref-text.d.ts.map +1 -0
- package/dist/internal/ref-text.js +15 -0
- package/dist/internal/ref-text.js.map +1 -0
- package/dist/internal/session-runtime.d.ts +44 -0
- package/dist/internal/session-runtime.d.ts.map +1 -0
- package/dist/internal/session-runtime.js +143 -0
- package/dist/internal/session-runtime.js.map +1 -0
- package/dist/internal/watch-matcher.d.ts +56 -0
- package/dist/internal/watch-matcher.d.ts.map +1 -0
- package/dist/internal/watch-matcher.js +179 -0
- package/dist/internal/watch-matcher.js.map +1 -0
- package/dist/loader.d.ts +97 -0
- package/dist/loader.d.ts.map +1 -0
- package/dist/loader.js +527 -0
- package/dist/loader.js.map +1 -0
- package/dist/observer-dispatcher.d.ts +69 -0
- package/dist/observer-dispatcher.d.ts.map +1 -0
- package/dist/observer-dispatcher.js +184 -0
- package/dist/observer-dispatcher.js.map +1 -0
- package/dist/plugin-merger.d.ts +175 -0
- package/dist/plugin-merger.d.ts.map +1 -0
- package/dist/plugin-merger.js +450 -0
- package/dist/plugin-merger.js.map +1 -0
- package/dist/plugins/git/branch-tracker.d.ts +85 -0
- package/dist/plugins/git/branch-tracker.d.ts.map +1 -0
- package/dist/plugins/git/branch-tracker.js +181 -0
- package/dist/plugins/git/branch-tracker.js.map +1 -0
- package/dist/plugins/git/cwd-extensions.d.ts +10 -0
- package/dist/plugins/git/cwd-extensions.d.ts.map +1 -0
- package/dist/plugins/git/cwd-extensions.js +155 -0
- package/dist/plugins/git/cwd-extensions.js.map +1 -0
- package/dist/plugins/git/git-ops.d.ts +89 -0
- package/dist/plugins/git/git-ops.d.ts.map +1 -0
- package/dist/plugins/git/git-ops.js +107 -0
- package/dist/plugins/git/git-ops.js.map +1 -0
- package/dist/plugins/git/index.d.ts +225 -0
- package/dist/plugins/git/index.d.ts.map +1 -0
- package/dist/plugins/git/index.js +52 -0
- package/dist/plugins/git/index.js.map +1 -0
- package/dist/plugins/git/predicates.d.ts +312 -0
- package/dist/plugins/git/predicates.d.ts.map +1 -0
- package/dist/plugins/git/predicates.js +580 -0
- package/dist/plugins/git/predicates.js.map +1 -0
- package/dist/plugins/git/rules.d.ts +209 -0
- package/dist/plugins/git/rules.d.ts.map +1 -0
- package/dist/plugins/git/rules.js +267 -0
- package/dist/plugins/git/rules.js.map +1 -0
- package/dist/schema.d.ts +1799 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +4 -0
- package/dist/schema.js.map +1 -0
- package/dist/testing/index.d.ts +621 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +747 -0
- package/dist/testing/index.js.map +1 -0
- package/examples/README.md +83 -0
- package/examples/combined-git-discipline/README.md +52 -0
- package/examples/combined-git-discipline/node_modules/.bin/pi-steering +43 -0
- package/examples/combined-git-discipline/package.json +18 -0
- package/examples/combined-git-discipline/steering.json +27 -0
- package/examples/combined-git-discipline/steering.test.ts +42 -0
- package/examples/combined-git-discipline/steering.ts +64 -0
- package/examples/combined-git-discipline/tsconfig.json +7 -0
- package/examples/draft-prs-only/README.md +42 -0
- package/examples/draft-prs-only/node_modules/.bin/pi-steering +43 -0
- package/examples/draft-prs-only/package.json +18 -0
- package/examples/draft-prs-only/steering.json +12 -0
- package/examples/draft-prs-only/steering.test.ts +48 -0
- package/examples/draft-prs-only/steering.ts +36 -0
- package/examples/draft-prs-only/tsconfig.json +7 -0
- package/examples/dynamic-reason-runtime-cwd/README.md +148 -0
- package/examples/dynamic-reason-runtime-cwd/node_modules/.bin/pi-steering +43 -0
- package/examples/dynamic-reason-runtime-cwd/package.json +18 -0
- package/examples/dynamic-reason-runtime-cwd/steering.test.ts +130 -0
- package/examples/dynamic-reason-runtime-cwd/steering.ts +82 -0
- package/examples/dynamic-reason-runtime-cwd/tsconfig.json +7 -0
- package/examples/force-push-strict/README.md +75 -0
- package/examples/force-push-strict/node_modules/.bin/pi-steering +43 -0
- package/examples/force-push-strict/package.json +18 -0
- package/examples/force-push-strict/steering.json +12 -0
- package/examples/force-push-strict/steering.test.ts +51 -0
- package/examples/force-push-strict/steering.ts +54 -0
- package/examples/force-push-strict/tsconfig.json +7 -0
- package/examples/no-amend/README.md +52 -0
- package/examples/no-amend/node_modules/.bin/pi-steering +43 -0
- package/examples/no-amend/package.json +18 -0
- package/examples/no-amend/steering.cwd-scoped.json +12 -0
- package/examples/no-amend/steering.json +11 -0
- package/examples/no-amend/steering.test.ts +49 -0
- package/examples/no-amend/steering.ts +38 -0
- package/examples/no-amend/tsconfig.json +7 -0
- package/examples/work-item-plugin/README.md +103 -0
- package/examples/work-item-plugin/node_modules/.bin/pi-steering +43 -0
- package/examples/work-item-plugin/package.json +19 -0
- package/examples/work-item-plugin/src/index.test.ts +168 -0
- package/examples/work-item-plugin/src/index.ts +122 -0
- package/examples/work-item-plugin/src/observers/npm-test-tracker.test.ts +95 -0
- package/examples/work-item-plugin/src/observers/npm-test-tracker.ts +88 -0
- package/examples/work-item-plugin/src/observers/retest-required-tracker.test.ts +81 -0
- package/examples/work-item-plugin/src/observers/retest-required-tracker.ts +81 -0
- package/examples/work-item-plugin/src/predicates/work-item-format.test.ts +157 -0
- package/examples/work-item-plugin/src/predicates/work-item-format.ts +99 -0
- package/examples/work-item-plugin/src/rules/commit-description-check.test.ts +107 -0
- package/examples/work-item-plugin/src/rules/commit-description-check.ts +83 -0
- package/examples/work-item-plugin/src/rules/commit-requires-work-item.test.ts +79 -0
- package/examples/work-item-plugin/src/rules/commit-requires-work-item.ts +56 -0
- package/examples/work-item-plugin/src/rules/push-requires-tests.test.ts +245 -0
- package/examples/work-item-plugin/src/rules/push-requires-tests.ts +62 -0
- package/examples/work-item-plugin/tsconfig.json +8 -0
- package/package.json +88 -0
- package/skills/steering-authoring/SKILL.md +102 -0
- package/src/INVARIANTS.md +116 -0
- package/src/__test-helpers__.ts +243 -0
- package/src/bin/pi-steering.test.ts +920 -0
- package/src/bin/pi-steering.ts +584 -0
- package/src/compat.test.ts +431 -0
- package/src/compat.ts +282 -0
- package/src/defaults.test.ts +585 -0
- package/src/defaults.ts +148 -0
- package/src/define-config.test.ts +888 -0
- package/src/define-config.ts +375 -0
- package/src/define-predicate.test.ts +70 -0
- package/src/define-predicate.ts +57 -0
- package/src/evaluator-internals/context.ts +290 -0
- package/src/evaluator-internals/override.test.ts +99 -0
- package/src/evaluator-internals/override.ts +83 -0
- package/src/evaluator-internals/predicates.ts +1037 -0
- package/src/evaluator-internals/speculative-synthesis.test.ts +411 -0
- package/src/evaluator-internals/speculative-synthesis.ts +199 -0
- package/src/evaluator.test.ts +6296 -0
- package/src/evaluator.ts +1106 -0
- package/src/factory-time-load.test.ts +630 -0
- package/src/helpers/walker-unknown-cwd-reason.test.ts +73 -0
- package/src/helpers/walker-unknown-cwd-reason.ts +83 -0
- package/src/index.test.ts +836 -0
- package/src/index.ts +229 -0
- package/src/internal/drop-unused-observers.test.ts +141 -0
- package/src/internal/drop-unused-observers.ts +81 -0
- package/src/internal/finalize-plugin-state.ts +43 -0
- package/src/internal/merge-observers.ts +51 -0
- package/src/internal/pattern-utils.test.ts +52 -0
- package/src/internal/pattern-utils.ts +38 -0
- package/src/internal/ref-text.test.ts +54 -0
- package/src/internal/ref-text.ts +17 -0
- package/src/internal/session-runtime.test.ts +629 -0
- package/src/internal/session-runtime.ts +201 -0
- package/src/internal/watch-matcher.test.ts +326 -0
- package/src/internal/watch-matcher.ts +200 -0
- package/src/jsdoc-propagation.test.ts +277 -0
- package/src/loader.test.ts +1098 -0
- package/src/loader.ts +581 -0
- package/src/not-block-onunknown.test.ts +554 -0
- package/src/observer-dispatcher.test.ts +939 -0
- package/src/observer-dispatcher.ts +290 -0
- package/src/per-predicate-typing.test.ts +361 -0
- package/src/plugin-merger.test.ts +790 -0
- package/src/plugin-merger.ts +598 -0
- package/src/plugins/git/README.md +485 -0
- package/src/plugins/git/branch-tracker.test.ts +281 -0
- package/src/plugins/git/branch-tracker.ts +187 -0
- package/src/plugins/git/cwd-extensions.test.ts +230 -0
- package/src/plugins/git/cwd-extensions.ts +160 -0
- package/src/plugins/git/git-ops.test.ts +397 -0
- package/src/plugins/git/git-ops.ts +173 -0
- package/src/plugins/git/index.ts +253 -0
- package/src/plugins/git/integration.test.ts +705 -0
- package/src/plugins/git/predicates.test.ts +1401 -0
- package/src/plugins/git/predicates.ts +693 -0
- package/src/plugins/git/rules.test.ts +1001 -0
- package/src/plugins/git/rules.ts +307 -0
- package/src/schema.test.ts +259 -0
- package/src/schema.ts +1963 -0
- package/src/testing/index.test.ts +1696 -0
- package/src/testing/index.ts +1472 -0
package/src/evaluator.ts
ADDED
|
@@ -0,0 +1,1106 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Part of pi-steering.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* v2 steering evaluator.
|
|
6
|
+
*
|
|
7
|
+
* Assembles the per-tool_call pipeline on top of:
|
|
8
|
+
*
|
|
9
|
+
* - `unbash-walker` — AST parse + command extraction +
|
|
10
|
+
* wrapper expansion + per-ref walker
|
|
11
|
+
* state (cwd today; branch/others once
|
|
12
|
+
* plugins register them).
|
|
13
|
+
* - {@link matchesPatternOrFn} / {@link evaluateWhen} — shared
|
|
14
|
+
* predicate resolution (see
|
|
15
|
+
* `./evaluator-internals/predicates.ts`).
|
|
16
|
+
* - {@link extractOverride} — inline override-comment detection
|
|
17
|
+
* ported from v1 (see
|
|
18
|
+
* `./evaluator-internals/override.ts`).
|
|
19
|
+
* - {@link createExecCache} / {@link createFindEntries} — per-call
|
|
20
|
+
* exec memoization + session-entry
|
|
21
|
+
* filtering (see
|
|
22
|
+
* `./evaluator-internals/context.ts`).
|
|
23
|
+
*
|
|
24
|
+
* Public surface is deliberately small: {@link buildEvaluator} returns
|
|
25
|
+
* an {@link EvaluatorRuntime} whose sole method, {@link
|
|
26
|
+
* EvaluatorRuntime.evaluate}, drives one `tool_call` event through
|
|
27
|
+
* every applicable rule. Phase 3c wires it into the pi extension's
|
|
28
|
+
* `tool_call` listener.
|
|
29
|
+
*
|
|
30
|
+
* Rule ordering (per ADR "Precedence: first-wins everywhere"):
|
|
31
|
+
*
|
|
32
|
+
* 1. `config.rules` — user's top-level rules, first-match-wins.
|
|
33
|
+
* 2. `resolved.rules` — plugin-shipped rules (already deduped /
|
|
34
|
+
* disabled-filtered by the plugin merger).
|
|
35
|
+
*
|
|
36
|
+
* First rule that fires AND isn't overridden wins and returns a block.
|
|
37
|
+
*
|
|
38
|
+
* Internal shape: each applicable rule is fed to {@link
|
|
39
|
+
* evaluateCandidate}, the single predicate-chain used for every tool.
|
|
40
|
+
* Bash rules loop over extracted command refs (one candidate per ref);
|
|
41
|
+
* write / edit produce exactly one candidate. The per-tool axes of
|
|
42
|
+
* variation live in the {@link Candidate} input — the body of
|
|
43
|
+
* `evaluateCandidate` stays tool-agnostic.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import type {
|
|
47
|
+
ExtensionContext,
|
|
48
|
+
ToolCallEvent,
|
|
49
|
+
ToolCallEventResult,
|
|
50
|
+
} from "@earendil-works/pi-coding-agent";
|
|
51
|
+
import { isToolCallEventType } from "@earendil-works/pi-coding-agent";
|
|
52
|
+
import {
|
|
53
|
+
type CommandRef,
|
|
54
|
+
cwdTracker,
|
|
55
|
+
type EnvState,
|
|
56
|
+
envTracker,
|
|
57
|
+
expandWrapperCommands,
|
|
58
|
+
extractAllCommandsFromAST,
|
|
59
|
+
getBasename,
|
|
60
|
+
type Modifier,
|
|
61
|
+
parse as parseBash,
|
|
62
|
+
type Tracker,
|
|
63
|
+
type Word,
|
|
64
|
+
walk,
|
|
65
|
+
} from "@cad0p/unbash-walker";
|
|
66
|
+
import {
|
|
67
|
+
createAppendEntry,
|
|
68
|
+
createExecCache,
|
|
69
|
+
createFindEntries,
|
|
70
|
+
createSessionEntryCache,
|
|
71
|
+
type EvaluatorHost,
|
|
72
|
+
} from "./evaluator-internals/context.ts";
|
|
73
|
+
import { extractOverride } from "./evaluator-internals/override.ts";
|
|
74
|
+
import {
|
|
75
|
+
evaluateWhen,
|
|
76
|
+
matchesPattern,
|
|
77
|
+
matchesPatternOrFn,
|
|
78
|
+
validateWhenClauseShape,
|
|
79
|
+
} from "./evaluator-internals/predicates.ts";
|
|
80
|
+
import {
|
|
81
|
+
type SpeculativeEventsByRef,
|
|
82
|
+
synthesizeSpeculativeEntries,
|
|
83
|
+
} from "./evaluator-internals/speculative-synthesis.ts";
|
|
84
|
+
import { mergeObserversUserFirst } from "./internal/merge-observers.ts";
|
|
85
|
+
import { refToText } from "./internal/ref-text.ts";
|
|
86
|
+
import type { ResolvedPluginState } from "./plugin-merger.ts";
|
|
87
|
+
import { validateName } from "./plugin-merger.ts";
|
|
88
|
+
import type {
|
|
89
|
+
Observer,
|
|
90
|
+
PredicateContext,
|
|
91
|
+
PredicateToolInput,
|
|
92
|
+
Rule,
|
|
93
|
+
SteeringConfig,
|
|
94
|
+
WhenWalkerState,
|
|
95
|
+
} from "./schema.ts";
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Built-in trackers
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Names of trackers the evaluator wires in directly (not via a plugin).
|
|
103
|
+
* `resolvePlugins` accepts this list as `knownBuiltinTrackers`: plugin
|
|
104
|
+
* `trackerExtensions` targeting these names are kept (so plugins can
|
|
105
|
+
* compose modifiers onto them) without emitting an `extension-orphan`
|
|
106
|
+
* diagnostic.
|
|
107
|
+
*
|
|
108
|
+
* All call sites (`buildSessionRuntime`, `loadHarness`,
|
|
109
|
+
* `loadSteeringConfig`, the `pi-steering list` CLI) import this
|
|
110
|
+
* constant so a future addition (e.g. an `argv` tracker) lights up
|
|
111
|
+
* uniformly across production and the test harness.
|
|
112
|
+
*/
|
|
113
|
+
export const EVALUATOR_BUILTIN_TRACKERS = ["cwd", "env"] as const;
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Public surface
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Runtime-facing evaluator handle. Phase 3c holds an instance per
|
|
121
|
+
* session and calls {@link evaluate} from the pi `tool_call`
|
|
122
|
+
* listener.
|
|
123
|
+
*/
|
|
124
|
+
export interface EvaluatorRuntime {
|
|
125
|
+
/**
|
|
126
|
+
* Evaluate a single `tool_call` event against every rule in
|
|
127
|
+
* `config.rules` + `resolved.rules`. Returns:
|
|
128
|
+
* - `{ block: true, reason }` — a rule matched + wasn't overridden.
|
|
129
|
+
* - `undefined` — no rule fires; tool call proceeds.
|
|
130
|
+
*/
|
|
131
|
+
evaluate(
|
|
132
|
+
event: ToolCallEvent,
|
|
133
|
+
ctx: ExtensionContext,
|
|
134
|
+
agentLoopIndex: number,
|
|
135
|
+
): Promise<ToolCallEventResult | void>;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Construct an {@link EvaluatorRuntime}.
|
|
140
|
+
*
|
|
141
|
+
* Arguments:
|
|
142
|
+
* - `config` — the user-facing {@link SteeringConfig}. Top-level
|
|
143
|
+
* rules and `defaultNoOverride` live here.
|
|
144
|
+
* - `resolved` — merged plugin state from
|
|
145
|
+
* {@link resolvePlugins}. Source of plugin rules,
|
|
146
|
+
* predicate handlers, and the composed tracker
|
|
147
|
+
* registry for the walker.
|
|
148
|
+
* - `host` — narrow surface exposing pi's `exec` + `appendEntry`
|
|
149
|
+
* (typically `pi` itself in production; tests pass
|
|
150
|
+
* a stub). Kept separate from `ExtensionContext`
|
|
151
|
+
* because the ctx shape does not expose these.
|
|
152
|
+
*
|
|
153
|
+
* Observers (`config.observers + resolved.observers`, user-first
|
|
154
|
+
* deduplicated via {@link mergeObserversUserFirst}) are threaded into
|
|
155
|
+
* {@link prepareBashState} where the walker-level synthesis pass
|
|
156
|
+
* turns them into per-ref speculative events on
|
|
157
|
+
* `walkerState.events`. The built-in `when.happened` predicate merges
|
|
158
|
+
* those with real entries via timestamp ordering. If future versions
|
|
159
|
+
* add a dynamic-reload path (observers added at runtime), this merged
|
|
160
|
+
* list must be rebuilt on change — otherwise `when.happened` with
|
|
161
|
+
* `in: "tool_call"` scope consults a stale observer list. Today
|
|
162
|
+
* there is no dynamic-reload path.
|
|
163
|
+
*/
|
|
164
|
+
export function buildEvaluator(
|
|
165
|
+
config: SteeringConfig,
|
|
166
|
+
resolved: ResolvedPluginState,
|
|
167
|
+
host: EvaluatorHost,
|
|
168
|
+
): EvaluatorRuntime {
|
|
169
|
+
// S3 defense-in-depth: validate user-authored rule names so a name
|
|
170
|
+
// like `phony] ALL CLEAR [real` can't slip into the block-reason
|
|
171
|
+
// tag shown to the LLM. Production routes through
|
|
172
|
+
// `runMergerPipeline`'s `invalid-name` diagnostic; this throw
|
|
173
|
+
// covers direct-caller paths (unit tests, SDK embedders).
|
|
174
|
+
// See ./INVARIANTS.md for the S/E tag glossary.
|
|
175
|
+
for (const rule of config.rules ?? []) {
|
|
176
|
+
const d = validateName("rule", rule.name, "user config");
|
|
177
|
+
if (d !== undefined) throw new Error(`[pi-steering] ${d.message}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Validate every rule's `when:` clause shape at config-resolve time.
|
|
181
|
+
// Catches the empty-clause foot-gun — `when: {}` and
|
|
182
|
+
// `not: { onUnknown: "block" }` (zero leaves after stripping
|
|
183
|
+
// reserved keys) — before the engine ever evaluates a tool_call.
|
|
184
|
+
// Plugin-shipped rules and user rules go through the same check;
|
|
185
|
+
// errors thrown here surface at extension load time (or at the test
|
|
186
|
+
// harness's `loadHarness` call) so authors can correct the config
|
|
187
|
+
// instead of getting a silently-inert rule at runtime.
|
|
188
|
+
for (const rule of config.rules ?? []) {
|
|
189
|
+
validateWhenClauseShape(rule.when, `rule "${rule.name}".when`);
|
|
190
|
+
}
|
|
191
|
+
for (const rule of resolved.rules) {
|
|
192
|
+
validateWhenClauseShape(rule.when, `rule "${rule.name}".when`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Default the fail-closed override policy per ADR "Override default".
|
|
196
|
+
const defaultNoOverride = config.defaultNoOverride ?? true;
|
|
197
|
+
|
|
198
|
+
// Combine config.rules (user-authored, first) with resolved.rules
|
|
199
|
+
// (plugin-shipped). Empty fallbacks mean a config without either slot
|
|
200
|
+
// still produces a running evaluator — just never fires.
|
|
201
|
+
const userRules = config.rules ?? [];
|
|
202
|
+
const pluginRules = resolved.rules;
|
|
203
|
+
const allRules: readonly Rule[] = [...userRules, ...pluginRules];
|
|
204
|
+
|
|
205
|
+
// Source tags per ADR §11: user-authored rules get `@user`, plugin-
|
|
206
|
+
// shipped rules get the originating plugin's name. The merger
|
|
207
|
+
// already tracks `rule-name → plugin-name` during resolution — we
|
|
208
|
+
// reuse that instead of threading the map through the evaluator.
|
|
209
|
+
const ruleSources = new Map<Rule, string>();
|
|
210
|
+
for (const rule of userRules) {
|
|
211
|
+
ruleSources.set(rule, "user");
|
|
212
|
+
}
|
|
213
|
+
for (const rule of pluginRules) {
|
|
214
|
+
ruleSources.set(rule, resolved.rulePluginOwners[rule.name] ?? "user");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Compose the walker's tracker registry. Must always include `cwd`
|
|
218
|
+
// and `env` so the built-in `when.cwd` predicate + cd's env-aware
|
|
219
|
+
// resolution work — even if no plugin ships them. Plugins extending
|
|
220
|
+
// these with their own modifiers are honored via
|
|
221
|
+
// `resolved.composedTrackers.{cwd,env}` (the plugin merger already
|
|
222
|
+
// layered extensions on top of the plugin-declared trackers, if any).
|
|
223
|
+
//
|
|
224
|
+
// When no plugin registers a `cwd` tracker, we fall back to the
|
|
225
|
+
// built-in `cwdTracker` AND layer any `trackerModifiers.cwd`
|
|
226
|
+
// extensions onto it (the plugin merger preserves extensions
|
|
227
|
+
// targeting `"cwd"` on the caller's behalf via the
|
|
228
|
+
// `knownBuiltinTrackers` hint passed to `resolvePlugins`). Same
|
|
229
|
+
// pattern for `env` — lets a future plugin add e.g. `.envrc`-style
|
|
230
|
+
// env loading as a new modifier on the shared tracker without
|
|
231
|
+
// replacing it.
|
|
232
|
+
//
|
|
233
|
+
// Env goes in first so cd's modifier sees the current ref's env via
|
|
234
|
+
// the `allState` read. Walker iteration is registration-order
|
|
235
|
+
// stable (Object.keys on an object literal); the ordering is a soft
|
|
236
|
+
// guarantee good for the built-in composition.
|
|
237
|
+
const trackers: Record<string, Tracker<unknown>> = {
|
|
238
|
+
...resolved.composedTrackers,
|
|
239
|
+
};
|
|
240
|
+
if (!("env" in trackers)) {
|
|
241
|
+
const extraEnvModifiers = resolved.trackerModifiers["env"];
|
|
242
|
+
trackers["env"] = composeBuiltinEnv(extraEnvModifiers) as Tracker<unknown>;
|
|
243
|
+
}
|
|
244
|
+
if (!("cwd" in trackers)) {
|
|
245
|
+
const extraCwdModifiers = resolved.trackerModifiers["cwd"];
|
|
246
|
+
trackers["cwd"] = composeBuiltinCwd(extraCwdModifiers) as Tracker<unknown>;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Merge user + plugin observers (user-first dedup via the shared
|
|
250
|
+
// helper, same convention as the observer-dispatcher). The merged
|
|
251
|
+
// list feeds the walker-level synthesis pass in
|
|
252
|
+
// {@link prepareBashState}, where eligible observers contribute
|
|
253
|
+
// speculative `walkerState.events` entries the built-in
|
|
254
|
+
// `when.happened` predicate consults alongside real entries. Without
|
|
255
|
+
// the dedup, a shadowed plugin observer's `writes` could produce
|
|
256
|
+
// synthetic entries that never match a real dispatch, re-creating
|
|
257
|
+
// the infinite-loop risk the speculative pass was designed to avoid.
|
|
258
|
+
const allObservers = mergeObserversUserFirst(
|
|
259
|
+
config.observers ?? [],
|
|
260
|
+
resolved.observers,
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
evaluate: (event, ctx, agentLoopIndex) =>
|
|
265
|
+
evaluateEvent(
|
|
266
|
+
event,
|
|
267
|
+
ctx,
|
|
268
|
+
agentLoopIndex,
|
|
269
|
+
allRules,
|
|
270
|
+
trackers,
|
|
271
|
+
resolved.predicates,
|
|
272
|
+
host,
|
|
273
|
+
defaultNoOverride,
|
|
274
|
+
ruleSources,
|
|
275
|
+
allObservers,
|
|
276
|
+
),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
// Per-event evaluation
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Layer a bucket of plugin-provided `{ basename -> Modifier[] }`
|
|
286
|
+
* extensions on top of the built-in {@link cwdTracker}, returning a
|
|
287
|
+
* fresh tracker so the built-in's `modifiers` map is never mutated.
|
|
288
|
+
*
|
|
289
|
+
* Used when no plugin registers a `cwd` tracker but plugins still
|
|
290
|
+
* want to add basename modifiers to the built-in one (e.g. the git
|
|
291
|
+
* plugin's `--git-dir=` handler). Mirrors the plugin-merger's
|
|
292
|
+
* `composeTracker` shape — kept local here because the merger's
|
|
293
|
+
* helper is private to that module and exposing it would force the
|
|
294
|
+
* merger to know about the built-in cwd tracker. Keeping the merger
|
|
295
|
+
* built-in-agnostic is worth the small duplication.
|
|
296
|
+
*/
|
|
297
|
+
function composeBuiltinCwd(
|
|
298
|
+
extras: Record<string, Modifier<unknown>[]> | undefined,
|
|
299
|
+
): Tracker<string> {
|
|
300
|
+
return composeBuiltin(cwdTracker, extras);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Layer a bucket of plugin-provided `{ basename -> Modifier[] }`
|
|
305
|
+
* extensions on top of the built-in {@link envTracker}, returning a
|
|
306
|
+
* fresh tracker so the built-in's `modifiers` map is never mutated.
|
|
307
|
+
*
|
|
308
|
+
* Parallels {@link composeBuiltinCwd}. Env extensions are a future
|
|
309
|
+
* surface — no plugin ships one today — but the composition is
|
|
310
|
+
* symmetric with cwd and costs one helper to keep both paths
|
|
311
|
+
* consistent when a plugin eventually wants to add e.g. `.envrc`-
|
|
312
|
+
* style env-loading under the same tracker.
|
|
313
|
+
*/
|
|
314
|
+
function composeBuiltinEnv(
|
|
315
|
+
extras: Record<string, Modifier<unknown>[]> | undefined,
|
|
316
|
+
): Tracker<EnvState> {
|
|
317
|
+
return composeBuiltin(envTracker, extras);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Generic tracker-extension compositor. Given a base tracker and a
|
|
322
|
+
* bucket of plugin-provided `{ basename -> Modifier[] }` extensions,
|
|
323
|
+
* returns a fresh tracker whose `modifiers` map fuses the two
|
|
324
|
+
* without mutating the base.
|
|
325
|
+
*
|
|
326
|
+
* Resolution rule per basename:
|
|
327
|
+
* - Base has none, extras has 1+: extras become the entry
|
|
328
|
+
* (unwrapped to a single Modifier when length is 1).
|
|
329
|
+
* - Base has one or many, extras has 1+: concatenated into an
|
|
330
|
+
* array ordered base-first, extras-after, so per-command
|
|
331
|
+
* overrides layer in the expected sequence.
|
|
332
|
+
*
|
|
333
|
+
* Used by {@link composeBuiltinCwd} and {@link composeBuiltinEnv}
|
|
334
|
+
* to fold `trackerExtensions.cwd` / `trackerExtensions.env` from
|
|
335
|
+
* plugin registrations onto the built-ins. Keeping this helper
|
|
336
|
+
* internal (not exported) lets the plugin-merger stay agnostic of
|
|
337
|
+
* which built-in trackers exist.
|
|
338
|
+
*/
|
|
339
|
+
function composeBuiltin<T>(
|
|
340
|
+
baseTracker: Tracker<T>,
|
|
341
|
+
extras: Record<string, Modifier<unknown>[]> | undefined,
|
|
342
|
+
): Tracker<T> {
|
|
343
|
+
if (!extras || Object.keys(extras).length === 0) return baseTracker;
|
|
344
|
+
const merged: Record<string, Modifier<T> | Modifier<T>[]> = {};
|
|
345
|
+
for (const [basename, mod] of Object.entries(baseTracker.modifiers)) {
|
|
346
|
+
merged[basename] = Array.isArray(mod) ? [...(mod as Modifier<T>[])] : mod;
|
|
347
|
+
}
|
|
348
|
+
for (const [basename, mods] of Object.entries(extras)) {
|
|
349
|
+
const existing = merged[basename];
|
|
350
|
+
const extrasTyped = mods as unknown as Modifier<T>[];
|
|
351
|
+
if (existing === undefined) {
|
|
352
|
+
merged[basename] =
|
|
353
|
+
extrasTyped.length === 1 ? extrasTyped[0]! : [...extrasTyped];
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
const existingList = Array.isArray(existing)
|
|
357
|
+
? (existing as Modifier<T>[])
|
|
358
|
+
: [existing as Modifier<T>];
|
|
359
|
+
merged[basename] = [...existingList, ...extrasTyped];
|
|
360
|
+
}
|
|
361
|
+
return { ...baseTracker, modifiers: merged };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Walker-state snapshot per extracted bash command ref plus the
|
|
366
|
+
* stringified `basename + args` text for regex testing, the basename
|
|
367
|
+
* sugar, and the suffix `Word[]` for quote-aware structured access.
|
|
368
|
+
*
|
|
369
|
+
* Built once per tool_call (in {@link prepareBashState}) so N rules
|
|
370
|
+
* against M refs cost N×M regex tests — no N parses or N walks, and
|
|
371
|
+
* `basename` / `args` are computed once per ref rather than per rule.
|
|
372
|
+
*/
|
|
373
|
+
interface BashRefState {
|
|
374
|
+
readonly ref: CommandRef;
|
|
375
|
+
readonly text: string;
|
|
376
|
+
readonly basename: string;
|
|
377
|
+
readonly args: readonly Word[];
|
|
378
|
+
readonly envAssignments: readonly Word[];
|
|
379
|
+
readonly walkerState: Readonly<WhenWalkerState>;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Prepare bash state for every rule to share: parse once, extract +
|
|
384
|
+
* expand wrappers once, walk trackers once, stringify each ref once.
|
|
385
|
+
*
|
|
386
|
+
* Also runs the walker-level speculative-entry synthesis pass and
|
|
387
|
+
* merges its output into each ref's walkerState under the reserved
|
|
388
|
+
* `events` key. The built-in `when.happened` predicate consults
|
|
389
|
+
* `ctx.walkerState.events[customType]` to unify real + speculative
|
|
390
|
+
* entries via timestamp ordering (see {@link evaluateHappened}).
|
|
391
|
+
*/
|
|
392
|
+
function prepareBashState(
|
|
393
|
+
command: string,
|
|
394
|
+
sessionCwd: string,
|
|
395
|
+
trackers: Record<string, Tracker<unknown>>,
|
|
396
|
+
observers: readonly Observer[],
|
|
397
|
+
): BashRefState[] {
|
|
398
|
+
const script = parseBash(command);
|
|
399
|
+
const extracted = extractAllCommandsFromAST(script, command);
|
|
400
|
+
const { commands: refs } = expandWrapperCommands(extracted);
|
|
401
|
+
const walkResult = walk(
|
|
402
|
+
script,
|
|
403
|
+
{ cwd: sessionCwd } as Record<string, unknown>,
|
|
404
|
+
trackers,
|
|
405
|
+
refs,
|
|
406
|
+
);
|
|
407
|
+
const speculativeEvents: SpeculativeEventsByRef =
|
|
408
|
+
synthesizeSpeculativeEntries(refs, observers);
|
|
409
|
+
return refs.map((ref) => {
|
|
410
|
+
const trackerState = walkResult.get(ref) ?? {
|
|
411
|
+
cwd: sessionCwd,
|
|
412
|
+
env: new Map<string, string>(),
|
|
413
|
+
};
|
|
414
|
+
const events = speculativeEvents.get(ref) ?? {};
|
|
415
|
+
return {
|
|
416
|
+
ref,
|
|
417
|
+
text: refToText(ref),
|
|
418
|
+
basename: getBasename(ref),
|
|
419
|
+
// `node.suffix` is the quote-aware Word[] for the ref. Exposed
|
|
420
|
+
// to predicates via PredicateToolInput.args; the walker already
|
|
421
|
+
// parsed it so we just pass it through.
|
|
422
|
+
args: ref.node.suffix,
|
|
423
|
+
// `node.prefix` is unbash's AssignmentPrefix[] (shape:
|
|
424
|
+
// `{ text, name, value, ... }`). Project into Word[] so
|
|
425
|
+
// PredicateToolInput.envAssignments lines up with `.args` for
|
|
426
|
+
// plugin consumers — `.text` preserves the full "KEY=VALUE"
|
|
427
|
+
// source token (with quoting), and dynamic values like `A=$VAR`
|
|
428
|
+
// come through visibly in `.text` so callers can detect them.
|
|
429
|
+
envAssignments: ref.node.prefix.map<Word>((p) => ({
|
|
430
|
+
text: p.text,
|
|
431
|
+
value: p.text,
|
|
432
|
+
pos: p.pos,
|
|
433
|
+
end: p.end,
|
|
434
|
+
})),
|
|
435
|
+
// Merge tracker state with synthesized events so the built-in
|
|
436
|
+
// `happened` predicate can read `walkerState.events` without
|
|
437
|
+
// threading a separate context field. Trackers cannot name a
|
|
438
|
+
// dimension `"events"` — the plugin merger rejects that (see
|
|
439
|
+
// plugin-merger.ts). The merge is a shallow copy so the walker's
|
|
440
|
+
// state object stays untouched for future evaluations.
|
|
441
|
+
//
|
|
442
|
+
// The cast via `unknown` to `Readonly<WhenWalkerState>` is safe:
|
|
443
|
+
// buildEvaluator always registers `cwd` + `env` trackers, so every
|
|
444
|
+
// ref the walker yields carries both fields; the fallback literal
|
|
445
|
+
// above also supplies them. The schema interface's `readonly
|
|
446
|
+
// [key: string]: unknown` index signature tolerates the `events`
|
|
447
|
+
// key and any plugin-registered tracker slot. TypeScript's
|
|
448
|
+
// spread inference over `Record<string, unknown> | { cwd: string;
|
|
449
|
+
// env: Map<...> }` doesn't preserve the cwd/env shape through
|
|
450
|
+
// the spread, so the double cast is the minimum TS needs to
|
|
451
|
+
// accept a structure its inference widens away.
|
|
452
|
+
walkerState: {
|
|
453
|
+
...trackerState,
|
|
454
|
+
events,
|
|
455
|
+
} as unknown as Readonly<WhenWalkerState>,
|
|
456
|
+
};
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Compute the effective `noOverride` for a rule — rule-level explicit
|
|
462
|
+
* value wins, falling back to the config-level default (itself defaulted
|
|
463
|
+
* to fail-closed `true` per ADR).
|
|
464
|
+
*/
|
|
465
|
+
function effectiveNoOverride(rule: Rule, defaultNoOverride: boolean): boolean {
|
|
466
|
+
return rule.noOverride ?? defaultNoOverride;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Format the block reason shown to the agent. Appends an override hint
|
|
471
|
+
* ONLY when the rule is overridable — rules with
|
|
472
|
+
* `noOverride: true` (or the fail-closed default) omit it to avoid
|
|
473
|
+
* advertising a nonexistent escape hatch.
|
|
474
|
+
*
|
|
475
|
+
* Source-tagged (per ADR §11): `[steering:<rule-name>@<source>] …`
|
|
476
|
+
* where `<source>` is the originating plugin name for plugin-shipped
|
|
477
|
+
* rules, or `user` for rules declared directly in the user's
|
|
478
|
+
* SteeringConfig.rules.
|
|
479
|
+
*
|
|
480
|
+
* Rule.reason accepts both a static string and a {@link ReasonFn}
|
|
481
|
+
* (D3 in pr5-tier-b-shell-var-tracker-spec.md). Function reasons
|
|
482
|
+
* receive the same {@link PredicateContext} the predicates saw;
|
|
483
|
+
* async returns are awaited before prefixing. A reason function
|
|
484
|
+
* that throws or rejects is logged via `console.warn` and replaced
|
|
485
|
+
* with a fail-safe fallback string — the block verdict still fires.
|
|
486
|
+
* The exact fallback text is a stable contract rule authors can
|
|
487
|
+
* detect in tests.
|
|
488
|
+
*
|
|
489
|
+
* Tag→body separator is paragraph-aware: when the resolved body
|
|
490
|
+
* contains a `\n\n` paragraph break, the tag is rendered on its own
|
|
491
|
+
* line (`${tag}\n\n${body}`) so subsequent paragraphs don't orphan
|
|
492
|
+
* visually from the source-tag prefix. Single-paragraph bodies keep
|
|
493
|
+
* the legacy single-space layout (`${tag} ${body}`) — backward-
|
|
494
|
+
* compatible for every reason that was single-paragraph before the
|
|
495
|
+
* paragraph-aware rendering shipped. Trigger is `\n\n` or its CRLF
|
|
496
|
+
* equivalent `\r\n\r\n` (defensive against bodies imported from
|
|
497
|
+
* Windows line-ending sources — CRLF templating layers, hand-typed
|
|
498
|
+
* Windows-IDE strings); a single `\n` inside an otherwise single-
|
|
499
|
+
* paragraph body keeps the single-space layout. The emitted
|
|
500
|
+
* separator is always normalized to `\n\n` regardless of which form
|
|
501
|
+
* triggered it.
|
|
502
|
+
*
|
|
503
|
+
* Body→override-hint separator mirrors the same paragraph-aware
|
|
504
|
+
* separator. Single-paragraph bodies keep the single-space prefix
|
|
505
|
+
* on the override hint (byte-identical to the pre-paragraph-aware
|
|
506
|
+
* rendering); multi-paragraph bodies promote the override hint to
|
|
507
|
+
* its own paragraph (`${body}\n\n${hint}`) so the safety paragraph
|
|
508
|
+
* stays visually standalone rather than running on into an inline
|
|
509
|
+
* "To override" sentence. Mirror docs on {@link Rule.reason}.
|
|
510
|
+
*/
|
|
511
|
+
async function formatReason(
|
|
512
|
+
rule: Rule,
|
|
513
|
+
tool: "bash" | "write" | "edit",
|
|
514
|
+
noOverride: boolean,
|
|
515
|
+
source: string,
|
|
516
|
+
ctx: PredicateContext,
|
|
517
|
+
): Promise<string> {
|
|
518
|
+
const tag = `[steering:${rule.name}@${source}]`;
|
|
519
|
+
const body = await resolveReasonBody(rule, source, ctx);
|
|
520
|
+
// Paragraph-aware tag separator — see function-level JSDoc for the
|
|
521
|
+
// contract; this line implements the trigger detection.
|
|
522
|
+
const multiPara = body.includes("\n\n") || body.includes("\r\n\r\n");
|
|
523
|
+
const separator = multiPara ? "\n\n" : " ";
|
|
524
|
+
if (noOverride) return `${tag}${separator}${body}`;
|
|
525
|
+
const leader = tool === "bash" ? "#" : "//";
|
|
526
|
+
const hint =
|
|
527
|
+
`To override, include a comment: ` +
|
|
528
|
+
`\`${leader} steering-override: ${rule.name} — <reason>\`.`;
|
|
529
|
+
return `${tag}${separator}${body}${separator}${hint}`;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Resolve the string body of a rule's reason field. Handles both
|
|
534
|
+
* variants of the discriminated union on {@link Rule.reason}:
|
|
535
|
+
*
|
|
536
|
+
* - `string` — returned as-is.
|
|
537
|
+
* - `ReasonFn` — invoked with `ctx`, awaited, returned. A
|
|
538
|
+
* synchronous throw or rejected promise is
|
|
539
|
+
* caught, logged to `console.warn` with the
|
|
540
|
+
* rule name + source prefix + error message
|
|
541
|
+
* + stack, and replaced with the fail-safe
|
|
542
|
+
* fallback body `(reason failed to format;
|
|
543
|
+
* see log)`. The wrapping in
|
|
544
|
+
* {@link formatReason} still adds the source
|
|
545
|
+
* tag, so the agent sees
|
|
546
|
+
* `[steering:<rule>@<source>] (reason failed
|
|
547
|
+
* to format; see log)` — an unambiguous
|
|
548
|
+
* signal of a broken reason fn that still
|
|
549
|
+
* doesn't leak the error message.
|
|
550
|
+
*
|
|
551
|
+
* The fallback behavior is part of the public contract per spec
|
|
552
|
+
* D3: a rule author CAN assert the exact text (e.g. in a test
|
|
553
|
+
* asserting the engine keeps the block verdict alive when the
|
|
554
|
+
* reason function intentionally throws as a smoke-test).
|
|
555
|
+
*/
|
|
556
|
+
async function resolveReasonBody(
|
|
557
|
+
rule: Rule,
|
|
558
|
+
source: string,
|
|
559
|
+
ctx: PredicateContext,
|
|
560
|
+
): Promise<string> {
|
|
561
|
+
if (typeof rule.reason === "string") return rule.reason;
|
|
562
|
+
try {
|
|
563
|
+
return await rule.reason(ctx);
|
|
564
|
+
} catch (err) {
|
|
565
|
+
const msg =
|
|
566
|
+
err instanceof Error ? `${err.message}\n${err.stack ?? ""}` : String(err);
|
|
567
|
+
console.warn(
|
|
568
|
+
`[pi-steering] Rule "${rule.name}"@${source}: reason function threw: ${msg}`,
|
|
569
|
+
);
|
|
570
|
+
return "(reason failed to format; see log)";
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// ---------------------------------------------------------------------------
|
|
575
|
+
// Unified per-candidate evaluation
|
|
576
|
+
// ---------------------------------------------------------------------------
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Per-tool_call state shared across every candidate and rule. One
|
|
580
|
+
* struct in place of the 6-argument bundle the prior shape threaded
|
|
581
|
+
* through both bash and write/edit call-sites.
|
|
582
|
+
*
|
|
583
|
+
* `exec` / `appendEntry` / `findEntries` are the closures the evaluator
|
|
584
|
+
* builds once per tool_call (see `./evaluator-internals/context.ts`).
|
|
585
|
+
* `appendEntry` auto-tags every write with the current
|
|
586
|
+
* `_agentLoopIndex`, including the `steering-override` audit entries
|
|
587
|
+
* written from the override-accepted path — so rules using
|
|
588
|
+
* `when.happened: { event: "steering-override", in: "agent_loop" }`
|
|
589
|
+
* can correctly filter override activity to the current agent loop.
|
|
590
|
+
*
|
|
591
|
+
* `host` is retained on the shared context for non-entry operations
|
|
592
|
+
* (currently only `exec` indirectly) and for tests that stub pi’s
|
|
593
|
+
* surface without having to re-shape every call-site.
|
|
594
|
+
*/
|
|
595
|
+
interface SharedEvalContext {
|
|
596
|
+
readonly agentLoopIndex: number;
|
|
597
|
+
readonly predicates: ResolvedPluginState["predicates"];
|
|
598
|
+
readonly exec: PredicateContext["exec"];
|
|
599
|
+
readonly appendEntry: PredicateContext["appendEntry"];
|
|
600
|
+
readonly findEntries: PredicateContext["findEntries"];
|
|
601
|
+
readonly host: EvaluatorHost;
|
|
602
|
+
readonly defaultNoOverride: boolean;
|
|
603
|
+
/**
|
|
604
|
+
* Rule → source-name lookup for source-tagged block reasons
|
|
605
|
+
* (`[steering:<rule>@<source>]`). Keyed by Rule object identity so
|
|
606
|
+
* the same rule name appearing in multiple plugins still resolves
|
|
607
|
+
* unambiguously.
|
|
608
|
+
*/
|
|
609
|
+
readonly ruleSources: ReadonlyMap<Rule, string>;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Single-candidate input for {@link evaluateCandidate}. The fields here
|
|
614
|
+
* are the sole per-tool axes of variation — the body of
|
|
615
|
+
* `evaluateCandidate` stays tool-agnostic.
|
|
616
|
+
*
|
|
617
|
+
* - `target` — string the rule's `pattern` / `requires` /
|
|
618
|
+
* `unless` test against (bash: basename + args
|
|
619
|
+
* for the current ref; write: content or path;
|
|
620
|
+
* edit: joined newText or path).
|
|
621
|
+
* - `cwd` — effective cwd seen by predicates via
|
|
622
|
+
* `ctx.cwd`. Per-ref for bash (walker-resolved);
|
|
623
|
+
* session cwd for write / edit.
|
|
624
|
+
* - `input` — the `PredicateToolInput` predicates see via
|
|
625
|
+
* `ctx.input`.
|
|
626
|
+
* - `overrideCarrier` — text scanned for `# steering-override: …`
|
|
627
|
+
* comments. Bash: the raw tool_call command;
|
|
628
|
+
* write: content; edit: joined newText.
|
|
629
|
+
* - `tool` — plain-string tool, drives the override-comment
|
|
630
|
+
* leader (`#` vs `//`) and the block reason.
|
|
631
|
+
* - `overrideEntryExtras` — extra fields merged into the
|
|
632
|
+
* `steering-override` audit entry
|
|
633
|
+
* (`command` for bash, `path` for
|
|
634
|
+
* write / edit).
|
|
635
|
+
*/
|
|
636
|
+
interface Candidate {
|
|
637
|
+
readonly target: string;
|
|
638
|
+
readonly cwd: string;
|
|
639
|
+
readonly input: PredicateToolInput;
|
|
640
|
+
readonly overrideCarrier: string;
|
|
641
|
+
readonly tool: "bash" | "write" | "edit";
|
|
642
|
+
readonly overrideEntryExtras: Record<string, string>;
|
|
643
|
+
/**
|
|
644
|
+
* Walker state snapshot for this candidate. Bash candidates carry
|
|
645
|
+
* the per-ref walk result (including synthesized
|
|
646
|
+
* `events: Record<customType, SyntheticEntry[]>` under the reserved
|
|
647
|
+
* `events` key, populated by the walker-level speculative-entry
|
|
648
|
+
* synthesis pass); write / edit candidates leave it undefined (no
|
|
649
|
+
* walker ran).
|
|
650
|
+
*/
|
|
651
|
+
readonly walkerState?: Readonly<WhenWalkerState>;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Outcome of `evaluateCandidate`:
|
|
656
|
+
* - {@link ToolCallEventResult} — rule fired + was NOT overridden.
|
|
657
|
+
* Caller returns this to stop
|
|
658
|
+
* evaluation for the whole event.
|
|
659
|
+
* - `"no-fire"` — rule didn't match this candidate.
|
|
660
|
+
* Caller continues to the next
|
|
661
|
+
* candidate (bash) or next rule
|
|
662
|
+
* (write / edit).
|
|
663
|
+
* - `"overridden"` — rule fired but an override comment
|
|
664
|
+
* was accepted + audit-logged.
|
|
665
|
+
* Caller moves to the next rule;
|
|
666
|
+
* for bash that also means stopping
|
|
667
|
+
* the ref loop (override covers the
|
|
668
|
+
* whole tool_call per v1 semantics).
|
|
669
|
+
*/
|
|
670
|
+
type CandidateOutcome = ToolCallEventResult | "no-fire" | "overridden";
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Run a rule's predicate chain (pattern → requires → unless → when).
|
|
674
|
+
* Returns the built {@link PredicateContext} when every predicate
|
|
675
|
+
* passes (rule fires), or `null` when the chain short-circuits to
|
|
676
|
+
* "no-fire" — **either** because a predicate legitimately rejected
|
|
677
|
+
* the candidate, **or** because a predicate threw.
|
|
678
|
+
*
|
|
679
|
+
* Throws are the S1 hardening: a predicate function (built-in or
|
|
680
|
+
* plugin-supplied) that throws synchronously or rejects asynchronously
|
|
681
|
+
* gets its error logged with the rule name + source and the rule is
|
|
682
|
+
* treated as NOT firing. Evaluation continues with the next rule.
|
|
683
|
+
*
|
|
684
|
+
* Why "does not fire" (vs "block" / "abort the whole evaluate"):
|
|
685
|
+
* - Mirrors the observer-dispatcher's per-observer isolation —
|
|
686
|
+
* one broken predicate must not poison the rest of the rule list.
|
|
687
|
+
* - A buggy predicate blocking everything would be worse UX than
|
|
688
|
+
* a buggy predicate silently failing — the block reason would
|
|
689
|
+
* leak the raw error message to the LLM (the pre-hardening
|
|
690
|
+
* behaviour). Top-level engine-throws still fail CLOSED; see
|
|
691
|
+
* {@link evaluateEvent}.
|
|
692
|
+
*/
|
|
693
|
+
async function runPredicateChain(
|
|
694
|
+
rule: Rule,
|
|
695
|
+
cand: Candidate,
|
|
696
|
+
shared: SharedEvalContext,
|
|
697
|
+
): Promise<PredicateContext | null> {
|
|
698
|
+
const source = shared.ruleSources.get(rule) ?? "user";
|
|
699
|
+
try {
|
|
700
|
+
// Pattern-miss is the common case; exit before allocating ctx.
|
|
701
|
+
if (!matchesPattern(rule.pattern, cand.target)) return null;
|
|
702
|
+
|
|
703
|
+
const ctx: PredicateContext = {
|
|
704
|
+
cwd: cand.cwd,
|
|
705
|
+
tool: cand.tool,
|
|
706
|
+
input: cand.input,
|
|
707
|
+
agentLoopIndex: shared.agentLoopIndex,
|
|
708
|
+
exec: shared.exec,
|
|
709
|
+
appendEntry: shared.appendEntry,
|
|
710
|
+
findEntries: shared.findEntries,
|
|
711
|
+
...(cand.walkerState !== undefined
|
|
712
|
+
? { walkerState: cand.walkerState }
|
|
713
|
+
: {}),
|
|
714
|
+
};
|
|
715
|
+
|
|
716
|
+
if (rule.requires !== undefined) {
|
|
717
|
+
const ok = await matchesPatternOrFn(rule.requires, cand.target, ctx);
|
|
718
|
+
if (!ok) return null;
|
|
719
|
+
}
|
|
720
|
+
if (rule.unless !== undefined) {
|
|
721
|
+
const ok = await matchesPatternOrFn(rule.unless, cand.target, ctx);
|
|
722
|
+
if (ok) return null;
|
|
723
|
+
}
|
|
724
|
+
const whenOk = await evaluateWhen(
|
|
725
|
+
rule.when,
|
|
726
|
+
{ cwd: cand.cwd },
|
|
727
|
+
ctx,
|
|
728
|
+
shared.predicates,
|
|
729
|
+
rule.name,
|
|
730
|
+
source,
|
|
731
|
+
);
|
|
732
|
+
if (!whenOk) return null;
|
|
733
|
+
|
|
734
|
+
return ctx;
|
|
735
|
+
} catch (err) {
|
|
736
|
+
console.warn(
|
|
737
|
+
`[pi-steering] predicate threw for rule "${rule.name}"@${source}: ${formatError(err)}`,
|
|
738
|
+
);
|
|
739
|
+
return null;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* Evaluate one candidate against one rule. This is the single pipeline
|
|
745
|
+
* every tool funnels through — differences between bash, write, and
|
|
746
|
+
* edit live entirely in the {@link Candidate} input.
|
|
747
|
+
*
|
|
748
|
+
* Evaluation order (short-circuits on first failure):
|
|
749
|
+
*
|
|
750
|
+
* 1. `pattern` — required; if no match we exit before allocating
|
|
751
|
+
* the predicate context.
|
|
752
|
+
* 2. `requires` — optional AND.
|
|
753
|
+
* 3. `unless` — optional exemption.
|
|
754
|
+
* 4. `when` — clause tree (`cwd`, `not`, `condition`, plugin
|
|
755
|
+
* predicates).
|
|
756
|
+
*
|
|
757
|
+
* All four steps are wrapped in a try/catch via
|
|
758
|
+
* {@link runPredicateChain} — a throw is logged and treated as "rule
|
|
759
|
+
* did not fire". That way a buggy predicate neither short-circuits the
|
|
760
|
+
* whole rule list (a broken guardrail rule silently poisoning the
|
|
761
|
+
* rest) nor leaks its raw `error.message` back to the agent via a
|
|
762
|
+
* pi-level error tool_result.
|
|
763
|
+
*
|
|
764
|
+
* On rule fire, check for an override comment addressing the rule by
|
|
765
|
+
* name (unless the rule opts out of overrides). An accepted override
|
|
766
|
+
* logs a `steering-override` audit entry and returns `"overridden"`.
|
|
767
|
+
*/
|
|
768
|
+
async function evaluateCandidate(
|
|
769
|
+
rule: Rule,
|
|
770
|
+
cand: Candidate,
|
|
771
|
+
shared: SharedEvalContext,
|
|
772
|
+
): Promise<CandidateOutcome> {
|
|
773
|
+
const ctx = await runPredicateChain(rule, cand, shared);
|
|
774
|
+
if (ctx === null) return "no-fire";
|
|
775
|
+
|
|
776
|
+
// Rule fires. Check for override (if allowed) before committing to
|
|
777
|
+
// blocking.
|
|
778
|
+
const noOverride = effectiveNoOverride(rule, shared.defaultNoOverride);
|
|
779
|
+
if (!noOverride) {
|
|
780
|
+
const reason = extractOverride(cand.overrideCarrier, rule.name);
|
|
781
|
+
if (reason !== null) {
|
|
782
|
+
// Go through the wrapped `shared.appendEntry` so the
|
|
783
|
+
// `_agentLoopIndex` auto-tag lands on the audit entry. Rules
|
|
784
|
+
// using `when.happened: { event: "steering-override", in:
|
|
785
|
+
// "agent_loop" }` rely on the tag to filter overrides by the
|
|
786
|
+
// current loop; a direct `host.appendEntry` here would bypass
|
|
787
|
+
// the wrapper and leave the entry invisible to that predicate.
|
|
788
|
+
shared.appendEntry("steering-override", {
|
|
789
|
+
rule: rule.name,
|
|
790
|
+
reason,
|
|
791
|
+
...cand.overrideEntryExtras,
|
|
792
|
+
timestamp: new Date().toISOString(),
|
|
793
|
+
});
|
|
794
|
+
return "overridden";
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Block is going to fire. Run the optional side-effect hook before
|
|
799
|
+
// returning the verdict — rules using `onFire` to self-mark (e.g.
|
|
800
|
+
// "write a session entry so my next attempt this agent loop passes")
|
|
801
|
+
// need the write to land before the agent sees the block. Override
|
|
802
|
+
// paths above already returned, so onFire is skipped when the rule
|
|
803
|
+
// was overridden; fail-closed defaults with no override comment fall
|
|
804
|
+
// through here normally.
|
|
805
|
+
//
|
|
806
|
+
// Fail-closed semantics on onFire errors: a sync throw or rejected
|
|
807
|
+
// promise is logged and SWALLOWED — the block still returns. The
|
|
808
|
+
// block decision already passed every predicate; a broken
|
|
809
|
+
// best-effort side effect must not silently invalidate it. Mirrors
|
|
810
|
+
// the observer-dispatcher's per-observer try/catch (observers are
|
|
811
|
+
// isolated for the same reason).
|
|
812
|
+
if (rule.onFire) {
|
|
813
|
+
try {
|
|
814
|
+
await rule.onFire(ctx);
|
|
815
|
+
} catch (err) {
|
|
816
|
+
console.warn(
|
|
817
|
+
`[pi-steering] onFire for rule "${rule.name}" threw: ${formatError(err)}`,
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
return {
|
|
823
|
+
block: true,
|
|
824
|
+
reason: await formatReason(
|
|
825
|
+
rule,
|
|
826
|
+
cand.tool,
|
|
827
|
+
noOverride,
|
|
828
|
+
shared.ruleSources.get(rule) ?? "user",
|
|
829
|
+
ctx,
|
|
830
|
+
),
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
async function evaluateEvent(
|
|
835
|
+
event: ToolCallEvent,
|
|
836
|
+
ctx: ExtensionContext,
|
|
837
|
+
agentLoopIndex: number,
|
|
838
|
+
rules: readonly Rule[],
|
|
839
|
+
trackers: Record<string, Tracker<unknown>>,
|
|
840
|
+
predicates: ResolvedPluginState["predicates"],
|
|
841
|
+
host: EvaluatorHost,
|
|
842
|
+
defaultNoOverride: boolean,
|
|
843
|
+
ruleSources: ReadonlyMap<Rule, string>,
|
|
844
|
+
allObservers: readonly Observer[],
|
|
845
|
+
): Promise<ToolCallEventResult | void> {
|
|
846
|
+
// Top-level fail-closed wrap (S1). If the engine's own scaffolding
|
|
847
|
+
// throws — parse errors, walker bugs, corrupted session JSONL, etc.
|
|
848
|
+
// — we block the tool AS A SAFETY MEASURE and tag the reason so the
|
|
849
|
+
// agent sees it came from the engine, not from a rule or plugin.
|
|
850
|
+
// Per-predicate throws are handled one level down in
|
|
851
|
+
// {@link runPredicateChain} (treated as "rule does not fire"); this
|
|
852
|
+
// outer wrap only catches throws OUTSIDE the per-rule try/catch.
|
|
853
|
+
try {
|
|
854
|
+
return await evaluateEventInner(
|
|
855
|
+
event,
|
|
856
|
+
ctx,
|
|
857
|
+
agentLoopIndex,
|
|
858
|
+
rules,
|
|
859
|
+
trackers,
|
|
860
|
+
predicates,
|
|
861
|
+
host,
|
|
862
|
+
defaultNoOverride,
|
|
863
|
+
ruleSources,
|
|
864
|
+
allObservers,
|
|
865
|
+
);
|
|
866
|
+
} catch (err) {
|
|
867
|
+
console.error(`[pi-steering] steering engine threw: ${formatError(err)}`);
|
|
868
|
+
return {
|
|
869
|
+
block: true,
|
|
870
|
+
reason:
|
|
871
|
+
"[steering:engine@internal] steering engine error; " +
|
|
872
|
+
"tool blocked as a safety measure",
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
async function evaluateEventInner(
|
|
878
|
+
event: ToolCallEvent,
|
|
879
|
+
ctx: ExtensionContext,
|
|
880
|
+
agentLoopIndex: number,
|
|
881
|
+
rules: readonly Rule[],
|
|
882
|
+
trackers: Record<string, Tracker<unknown>>,
|
|
883
|
+
predicates: ResolvedPluginState["predicates"],
|
|
884
|
+
host: EvaluatorHost,
|
|
885
|
+
defaultNoOverride: boolean,
|
|
886
|
+
ruleSources: ReadonlyMap<Rule, string>,
|
|
887
|
+
allObservers: readonly Observer[],
|
|
888
|
+
): Promise<ToolCallEventResult | void> {
|
|
889
|
+
// Shared per-call closures: exec memoized by (cmd, args, cwd);
|
|
890
|
+
// findEntries reads the current session JSONL on demand; appendEntry
|
|
891
|
+
// auto-tags writes with `_agentLoopIndex` so rules using
|
|
892
|
+
// `when.happened` can filter by agent-loop scope.
|
|
893
|
+
//
|
|
894
|
+
// findEntries + appendEntry share a session-entry cache so a write
|
|
895
|
+
// performed by an earlier rule's onFire (or by the override-audit
|
|
896
|
+
// path) invalidates the cached read — later rules' when.happened
|
|
897
|
+
// predicates see the fresh write instead of a stale snapshot
|
|
898
|
+
// (S2/E1). The evaluator itself doesn't interleave writes with reads,
|
|
899
|
+
// but onFire + override-audit do.
|
|
900
|
+
const exec = createExecCache(host, ctx.cwd);
|
|
901
|
+
const entryCache = createSessionEntryCache();
|
|
902
|
+
const findEntries = createFindEntries(ctx, entryCache);
|
|
903
|
+
const appendEntry = createAppendEntry(host, agentLoopIndex, entryCache);
|
|
904
|
+
|
|
905
|
+
const shared: SharedEvalContext = {
|
|
906
|
+
agentLoopIndex,
|
|
907
|
+
predicates,
|
|
908
|
+
exec,
|
|
909
|
+
appendEntry,
|
|
910
|
+
findEntries,
|
|
911
|
+
host,
|
|
912
|
+
defaultNoOverride,
|
|
913
|
+
ruleSources,
|
|
914
|
+
};
|
|
915
|
+
|
|
916
|
+
// Bash state is lazy: non-bash rules don't pay for parse / walk.
|
|
917
|
+
let bashState: BashRefState[] | null = null;
|
|
918
|
+
const bashEvent = isToolCallEventType("bash", event) ? event : null;
|
|
919
|
+
|
|
920
|
+
// Edit events share `allNewText` across every field="content" rule.
|
|
921
|
+
// Computed lazily on the first edit rule so a config with only bash /
|
|
922
|
+
// write rules doesn't pay the join cost. `null` sentinel is safe
|
|
923
|
+
// because `edits` is always a non-null array on edit events.
|
|
924
|
+
const editEvent = isToolCallEventType("edit", event) ? event : null;
|
|
925
|
+
let editAllNewText: string | null = null;
|
|
926
|
+
|
|
927
|
+
for (const rule of rules) {
|
|
928
|
+
if (rule.tool !== event.toolName) continue;
|
|
929
|
+
|
|
930
|
+
if (rule.tool === "bash") {
|
|
931
|
+
if (!bashEvent) continue;
|
|
932
|
+
if (bashState === null) {
|
|
933
|
+
bashState = prepareBashState(
|
|
934
|
+
bashEvent.input.command,
|
|
935
|
+
ctx.cwd,
|
|
936
|
+
trackers,
|
|
937
|
+
allObservers,
|
|
938
|
+
);
|
|
939
|
+
}
|
|
940
|
+
const result = await evaluateBashRule(
|
|
941
|
+
rule,
|
|
942
|
+
bashEvent.input.command,
|
|
943
|
+
bashState,
|
|
944
|
+
shared,
|
|
945
|
+
);
|
|
946
|
+
if (result !== undefined) return result;
|
|
947
|
+
continue;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
if (rule.tool === "write" && isToolCallEventType("write", event)) {
|
|
951
|
+
const target =
|
|
952
|
+
rule.field === "path" ? event.input.path : event.input.content;
|
|
953
|
+
const result = await evaluateWriteEditRule(
|
|
954
|
+
rule,
|
|
955
|
+
{
|
|
956
|
+
tool: "write",
|
|
957
|
+
path: event.input.path,
|
|
958
|
+
content: event.input.content,
|
|
959
|
+
// Shell env assignments don't apply to file-surface tools;
|
|
960
|
+
// shape as `[]` rather than `undefined` so plugin authors
|
|
961
|
+
// can treat the field uniformly across tools.
|
|
962
|
+
envAssignments: [],
|
|
963
|
+
},
|
|
964
|
+
target,
|
|
965
|
+
// override-comment scanned against content (the natural
|
|
966
|
+
// carrier for write override comments — v1 parity).
|
|
967
|
+
event.input.content,
|
|
968
|
+
event.input.path,
|
|
969
|
+
ctx.cwd,
|
|
970
|
+
shared,
|
|
971
|
+
);
|
|
972
|
+
if (result !== undefined) return result;
|
|
973
|
+
continue;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
if (rule.tool === "edit" && editEvent) {
|
|
977
|
+
// Joined newText is needed as override carrier for EVERY edit
|
|
978
|
+
// rule plus as `target` for field="content" rules. Compute once
|
|
979
|
+
// per tool_call on the first edit rule, reuse for the rest.
|
|
980
|
+
if (editAllNewText === null) {
|
|
981
|
+
editAllNewText = editEvent.input.edits.map((e) => e.newText).join("\n");
|
|
982
|
+
}
|
|
983
|
+
const target =
|
|
984
|
+
rule.field === "path" ? editEvent.input.path : editAllNewText;
|
|
985
|
+
const result = await evaluateWriteEditRule(
|
|
986
|
+
rule,
|
|
987
|
+
{
|
|
988
|
+
tool: "edit",
|
|
989
|
+
path: editEvent.input.path,
|
|
990
|
+
edits: editEvent.input.edits,
|
|
991
|
+
// See the write branch above: `[]` for uniform shape.
|
|
992
|
+
envAssignments: [],
|
|
993
|
+
},
|
|
994
|
+
target,
|
|
995
|
+
editAllNewText,
|
|
996
|
+
editEvent.input.path,
|
|
997
|
+
ctx.cwd,
|
|
998
|
+
shared,
|
|
999
|
+
);
|
|
1000
|
+
if (result !== undefined) return result;
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
return undefined;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/**
|
|
1007
|
+
* Per-rule bash evaluation. Iterates every extracted command ref as
|
|
1008
|
+
* a {@link Candidate}. The first ref that fires the rule (pattern +
|
|
1009
|
+
* requires + unless + when) decides the verdict. Per v1 semantics, an
|
|
1010
|
+
* accepted override covers the whole tool_call — we stop scanning
|
|
1011
|
+
* further refs and hand control back to the caller.
|
|
1012
|
+
*/
|
|
1013
|
+
async function evaluateBashRule(
|
|
1014
|
+
rule: Rule,
|
|
1015
|
+
rawCommand: string,
|
|
1016
|
+
state: BashRefState[],
|
|
1017
|
+
shared: SharedEvalContext,
|
|
1018
|
+
): Promise<ToolCallEventResult | void> {
|
|
1019
|
+
for (const refState of state) {
|
|
1020
|
+
const cand: Candidate = {
|
|
1021
|
+
target: refState.text,
|
|
1022
|
+
cwd:
|
|
1023
|
+
typeof refState.walkerState["cwd"] === "string"
|
|
1024
|
+
? (refState.walkerState["cwd"] as string)
|
|
1025
|
+
: "unknown",
|
|
1026
|
+
input: {
|
|
1027
|
+
tool: "bash",
|
|
1028
|
+
command: refState.text,
|
|
1029
|
+
basename: refState.basename,
|
|
1030
|
+
args: refState.args,
|
|
1031
|
+
envAssignments: refState.envAssignments,
|
|
1032
|
+
},
|
|
1033
|
+
overrideCarrier: rawCommand,
|
|
1034
|
+
tool: "bash",
|
|
1035
|
+
overrideEntryExtras: { command: rawCommand },
|
|
1036
|
+
walkerState: refState.walkerState,
|
|
1037
|
+
};
|
|
1038
|
+
const r = await evaluateCandidate(rule, cand, shared);
|
|
1039
|
+
if (r === "no-fire") continue;
|
|
1040
|
+
if (r === "overridden") return undefined; // v1: override covers whole tool_call
|
|
1041
|
+
return r;
|
|
1042
|
+
}
|
|
1043
|
+
return undefined;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/**
|
|
1047
|
+
* Per-rule write / edit evaluation. Produces a single {@link Candidate}
|
|
1048
|
+
* and defers to {@link evaluateCandidate}.
|
|
1049
|
+
*
|
|
1050
|
+
* `target` is the pre-resolved string the rule's pattern tests against
|
|
1051
|
+
* — the caller computes it once per rule (reading `path` or the joined
|
|
1052
|
+
* `newText`), which lets edit tool_calls share the join across every
|
|
1053
|
+
* field="content" rule. `overrideCarrier` is the text scanned for
|
|
1054
|
+
* override comments (per v1 parity, content / joined newText even for
|
|
1055
|
+
* field="path" rules).
|
|
1056
|
+
*/
|
|
1057
|
+
async function evaluateWriteEditRule(
|
|
1058
|
+
rule: Rule,
|
|
1059
|
+
input: PredicateToolInput,
|
|
1060
|
+
target: string,
|
|
1061
|
+
overrideCarrier: string,
|
|
1062
|
+
path: string,
|
|
1063
|
+
sessionCwd: string,
|
|
1064
|
+
shared: SharedEvalContext,
|
|
1065
|
+
): Promise<ToolCallEventResult | void> {
|
|
1066
|
+
const cand: Candidate = {
|
|
1067
|
+
target,
|
|
1068
|
+
cwd: sessionCwd,
|
|
1069
|
+
input,
|
|
1070
|
+
overrideCarrier,
|
|
1071
|
+
tool: rule.tool as "write" | "edit",
|
|
1072
|
+
overrideEntryExtras: { path },
|
|
1073
|
+
};
|
|
1074
|
+
const r = await evaluateCandidate(rule, cand, shared);
|
|
1075
|
+
if (r === "no-fire" || r === "overridden") return undefined;
|
|
1076
|
+
return r;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// ---------------------------------------------------------------------------
|
|
1080
|
+
// Error formatting
|
|
1081
|
+
// ---------------------------------------------------------------------------
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* Format an unknown thrown value for a warning log. Shared across the
|
|
1085
|
+
* three places the evaluator catches throws:
|
|
1086
|
+
*
|
|
1087
|
+
* - per-predicate try/catch in {@link runPredicateChain} (S1).
|
|
1088
|
+
* - per-rule `onFire` try/catch in {@link evaluateCandidate}.
|
|
1089
|
+
* - top-level engine try/catch in {@link evaluateEvent}.
|
|
1090
|
+
*
|
|
1091
|
+
* Mirrors the observer-dispatcher's `formatError` so the log shape
|
|
1092
|
+
* stays consistent across the two hook surfaces: `message\nstack` for
|
|
1093
|
+
* proper Errors, best-effort JSON otherwise, falling through to
|
|
1094
|
+
* `String(err)`.
|
|
1095
|
+
*/
|
|
1096
|
+
function formatError(err: unknown): string {
|
|
1097
|
+
if (err instanceof Error) return `${err.message}\n${err.stack ?? ""}`;
|
|
1098
|
+
try {
|
|
1099
|
+
return JSON.stringify(err);
|
|
1100
|
+
} catch {
|
|
1101
|
+
return String(err);
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// Re-export supporting types for consumers embedding the evaluator.
|
|
1106
|
+
export type { EvaluatorHost } from "./evaluator-internals/context.ts";
|