@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
|
@@ -0,0 +1,714 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Part of pi-steering.
|
|
3
|
+
import { isToolCallEventType } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { cwdTracker, envTracker, expandWrapperCommands, extractAllCommandsFromAST, getBasename, parse as parseBash, walk, } from "@cad0p/unbash-walker";
|
|
5
|
+
import { createAppendEntry, createExecCache, createFindEntries, createSessionEntryCache, } from "./evaluator-internals/context.js";
|
|
6
|
+
import { extractOverride } from "./evaluator-internals/override.js";
|
|
7
|
+
import { evaluateWhen, matchesPattern, matchesPatternOrFn, validateWhenClauseShape, } from "./evaluator-internals/predicates.js";
|
|
8
|
+
import { synthesizeSpeculativeEntries, } from "./evaluator-internals/speculative-synthesis.js";
|
|
9
|
+
import { mergeObserversUserFirst } from "./internal/merge-observers.js";
|
|
10
|
+
import { refToText } from "./internal/ref-text.js";
|
|
11
|
+
import { validateName } from "./plugin-merger.js";
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Built-in trackers
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
/**
|
|
16
|
+
* Names of trackers the evaluator wires in directly (not via a plugin).
|
|
17
|
+
* `resolvePlugins` accepts this list as `knownBuiltinTrackers`: plugin
|
|
18
|
+
* `trackerExtensions` targeting these names are kept (so plugins can
|
|
19
|
+
* compose modifiers onto them) without emitting an `extension-orphan`
|
|
20
|
+
* diagnostic.
|
|
21
|
+
*
|
|
22
|
+
* All call sites (`buildSessionRuntime`, `loadHarness`,
|
|
23
|
+
* `loadSteeringConfig`, the `pi-steering list` CLI) import this
|
|
24
|
+
* constant so a future addition (e.g. an `argv` tracker) lights up
|
|
25
|
+
* uniformly across production and the test harness.
|
|
26
|
+
*/
|
|
27
|
+
export const EVALUATOR_BUILTIN_TRACKERS = ["cwd", "env"];
|
|
28
|
+
/**
|
|
29
|
+
* Construct an {@link EvaluatorRuntime}.
|
|
30
|
+
*
|
|
31
|
+
* Arguments:
|
|
32
|
+
* - `config` — the user-facing {@link SteeringConfig}. Top-level
|
|
33
|
+
* rules and `defaultNoOverride` live here.
|
|
34
|
+
* - `resolved` — merged plugin state from
|
|
35
|
+
* {@link resolvePlugins}. Source of plugin rules,
|
|
36
|
+
* predicate handlers, and the composed tracker
|
|
37
|
+
* registry for the walker.
|
|
38
|
+
* - `host` — narrow surface exposing pi's `exec` + `appendEntry`
|
|
39
|
+
* (typically `pi` itself in production; tests pass
|
|
40
|
+
* a stub). Kept separate from `ExtensionContext`
|
|
41
|
+
* because the ctx shape does not expose these.
|
|
42
|
+
*
|
|
43
|
+
* Observers (`config.observers + resolved.observers`, user-first
|
|
44
|
+
* deduplicated via {@link mergeObserversUserFirst}) are threaded into
|
|
45
|
+
* {@link prepareBashState} where the walker-level synthesis pass
|
|
46
|
+
* turns them into per-ref speculative events on
|
|
47
|
+
* `walkerState.events`. The built-in `when.happened` predicate merges
|
|
48
|
+
* those with real entries via timestamp ordering. If future versions
|
|
49
|
+
* add a dynamic-reload path (observers added at runtime), this merged
|
|
50
|
+
* list must be rebuilt on change — otherwise `when.happened` with
|
|
51
|
+
* `in: "tool_call"` scope consults a stale observer list. Today
|
|
52
|
+
* there is no dynamic-reload path.
|
|
53
|
+
*/
|
|
54
|
+
export function buildEvaluator(config, resolved, host) {
|
|
55
|
+
// S3 defense-in-depth: validate user-authored rule names so a name
|
|
56
|
+
// like `phony] ALL CLEAR [real` can't slip into the block-reason
|
|
57
|
+
// tag shown to the LLM. Production routes through
|
|
58
|
+
// `runMergerPipeline`'s `invalid-name` diagnostic; this throw
|
|
59
|
+
// covers direct-caller paths (unit tests, SDK embedders).
|
|
60
|
+
// See ./INVARIANTS.md for the S/E tag glossary.
|
|
61
|
+
for (const rule of config.rules ?? []) {
|
|
62
|
+
const d = validateName("rule", rule.name, "user config");
|
|
63
|
+
if (d !== undefined)
|
|
64
|
+
throw new Error(`[pi-steering] ${d.message}`);
|
|
65
|
+
}
|
|
66
|
+
// Validate every rule's `when:` clause shape at config-resolve time.
|
|
67
|
+
// Catches the empty-clause foot-gun — `when: {}` and
|
|
68
|
+
// `not: { onUnknown: "block" }` (zero leaves after stripping
|
|
69
|
+
// reserved keys) — before the engine ever evaluates a tool_call.
|
|
70
|
+
// Plugin-shipped rules and user rules go through the same check;
|
|
71
|
+
// errors thrown here surface at extension load time (or at the test
|
|
72
|
+
// harness's `loadHarness` call) so authors can correct the config
|
|
73
|
+
// instead of getting a silently-inert rule at runtime.
|
|
74
|
+
for (const rule of config.rules ?? []) {
|
|
75
|
+
validateWhenClauseShape(rule.when, `rule "${rule.name}".when`);
|
|
76
|
+
}
|
|
77
|
+
for (const rule of resolved.rules) {
|
|
78
|
+
validateWhenClauseShape(rule.when, `rule "${rule.name}".when`);
|
|
79
|
+
}
|
|
80
|
+
// Default the fail-closed override policy per ADR "Override default".
|
|
81
|
+
const defaultNoOverride = config.defaultNoOverride ?? true;
|
|
82
|
+
// Combine config.rules (user-authored, first) with resolved.rules
|
|
83
|
+
// (plugin-shipped). Empty fallbacks mean a config without either slot
|
|
84
|
+
// still produces a running evaluator — just never fires.
|
|
85
|
+
const userRules = config.rules ?? [];
|
|
86
|
+
const pluginRules = resolved.rules;
|
|
87
|
+
const allRules = [...userRules, ...pluginRules];
|
|
88
|
+
// Source tags per ADR §11: user-authored rules get `@user`, plugin-
|
|
89
|
+
// shipped rules get the originating plugin's name. The merger
|
|
90
|
+
// already tracks `rule-name → plugin-name` during resolution — we
|
|
91
|
+
// reuse that instead of threading the map through the evaluator.
|
|
92
|
+
const ruleSources = new Map();
|
|
93
|
+
for (const rule of userRules) {
|
|
94
|
+
ruleSources.set(rule, "user");
|
|
95
|
+
}
|
|
96
|
+
for (const rule of pluginRules) {
|
|
97
|
+
ruleSources.set(rule, resolved.rulePluginOwners[rule.name] ?? "user");
|
|
98
|
+
}
|
|
99
|
+
// Compose the walker's tracker registry. Must always include `cwd`
|
|
100
|
+
// and `env` so the built-in `when.cwd` predicate + cd's env-aware
|
|
101
|
+
// resolution work — even if no plugin ships them. Plugins extending
|
|
102
|
+
// these with their own modifiers are honored via
|
|
103
|
+
// `resolved.composedTrackers.{cwd,env}` (the plugin merger already
|
|
104
|
+
// layered extensions on top of the plugin-declared trackers, if any).
|
|
105
|
+
//
|
|
106
|
+
// When no plugin registers a `cwd` tracker, we fall back to the
|
|
107
|
+
// built-in `cwdTracker` AND layer any `trackerModifiers.cwd`
|
|
108
|
+
// extensions onto it (the plugin merger preserves extensions
|
|
109
|
+
// targeting `"cwd"` on the caller's behalf via the
|
|
110
|
+
// `knownBuiltinTrackers` hint passed to `resolvePlugins`). Same
|
|
111
|
+
// pattern for `env` — lets a future plugin add e.g. `.envrc`-style
|
|
112
|
+
// env loading as a new modifier on the shared tracker without
|
|
113
|
+
// replacing it.
|
|
114
|
+
//
|
|
115
|
+
// Env goes in first so cd's modifier sees the current ref's env via
|
|
116
|
+
// the `allState` read. Walker iteration is registration-order
|
|
117
|
+
// stable (Object.keys on an object literal); the ordering is a soft
|
|
118
|
+
// guarantee good for the built-in composition.
|
|
119
|
+
const trackers = {
|
|
120
|
+
...resolved.composedTrackers,
|
|
121
|
+
};
|
|
122
|
+
if (!("env" in trackers)) {
|
|
123
|
+
const extraEnvModifiers = resolved.trackerModifiers["env"];
|
|
124
|
+
trackers["env"] = composeBuiltinEnv(extraEnvModifiers);
|
|
125
|
+
}
|
|
126
|
+
if (!("cwd" in trackers)) {
|
|
127
|
+
const extraCwdModifiers = resolved.trackerModifiers["cwd"];
|
|
128
|
+
trackers["cwd"] = composeBuiltinCwd(extraCwdModifiers);
|
|
129
|
+
}
|
|
130
|
+
// Merge user + plugin observers (user-first dedup via the shared
|
|
131
|
+
// helper, same convention as the observer-dispatcher). The merged
|
|
132
|
+
// list feeds the walker-level synthesis pass in
|
|
133
|
+
// {@link prepareBashState}, where eligible observers contribute
|
|
134
|
+
// speculative `walkerState.events` entries the built-in
|
|
135
|
+
// `when.happened` predicate consults alongside real entries. Without
|
|
136
|
+
// the dedup, a shadowed plugin observer's `writes` could produce
|
|
137
|
+
// synthetic entries that never match a real dispatch, re-creating
|
|
138
|
+
// the infinite-loop risk the speculative pass was designed to avoid.
|
|
139
|
+
const allObservers = mergeObserversUserFirst(config.observers ?? [], resolved.observers);
|
|
140
|
+
return {
|
|
141
|
+
evaluate: (event, ctx, agentLoopIndex) => evaluateEvent(event, ctx, agentLoopIndex, allRules, trackers, resolved.predicates, host, defaultNoOverride, ruleSources, allObservers),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// Per-event evaluation
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
/**
|
|
148
|
+
* Layer a bucket of plugin-provided `{ basename -> Modifier[] }`
|
|
149
|
+
* extensions on top of the built-in {@link cwdTracker}, returning a
|
|
150
|
+
* fresh tracker so the built-in's `modifiers` map is never mutated.
|
|
151
|
+
*
|
|
152
|
+
* Used when no plugin registers a `cwd` tracker but plugins still
|
|
153
|
+
* want to add basename modifiers to the built-in one (e.g. the git
|
|
154
|
+
* plugin's `--git-dir=` handler). Mirrors the plugin-merger's
|
|
155
|
+
* `composeTracker` shape — kept local here because the merger's
|
|
156
|
+
* helper is private to that module and exposing it would force the
|
|
157
|
+
* merger to know about the built-in cwd tracker. Keeping the merger
|
|
158
|
+
* built-in-agnostic is worth the small duplication.
|
|
159
|
+
*/
|
|
160
|
+
function composeBuiltinCwd(extras) {
|
|
161
|
+
return composeBuiltin(cwdTracker, extras);
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Layer a bucket of plugin-provided `{ basename -> Modifier[] }`
|
|
165
|
+
* extensions on top of the built-in {@link envTracker}, returning a
|
|
166
|
+
* fresh tracker so the built-in's `modifiers` map is never mutated.
|
|
167
|
+
*
|
|
168
|
+
* Parallels {@link composeBuiltinCwd}. Env extensions are a future
|
|
169
|
+
* surface — no plugin ships one today — but the composition is
|
|
170
|
+
* symmetric with cwd and costs one helper to keep both paths
|
|
171
|
+
* consistent when a plugin eventually wants to add e.g. `.envrc`-
|
|
172
|
+
* style env-loading under the same tracker.
|
|
173
|
+
*/
|
|
174
|
+
function composeBuiltinEnv(extras) {
|
|
175
|
+
return composeBuiltin(envTracker, extras);
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Generic tracker-extension compositor. Given a base tracker and a
|
|
179
|
+
* bucket of plugin-provided `{ basename -> Modifier[] }` extensions,
|
|
180
|
+
* returns a fresh tracker whose `modifiers` map fuses the two
|
|
181
|
+
* without mutating the base.
|
|
182
|
+
*
|
|
183
|
+
* Resolution rule per basename:
|
|
184
|
+
* - Base has none, extras has 1+: extras become the entry
|
|
185
|
+
* (unwrapped to a single Modifier when length is 1).
|
|
186
|
+
* - Base has one or many, extras has 1+: concatenated into an
|
|
187
|
+
* array ordered base-first, extras-after, so per-command
|
|
188
|
+
* overrides layer in the expected sequence.
|
|
189
|
+
*
|
|
190
|
+
* Used by {@link composeBuiltinCwd} and {@link composeBuiltinEnv}
|
|
191
|
+
* to fold `trackerExtensions.cwd` / `trackerExtensions.env` from
|
|
192
|
+
* plugin registrations onto the built-ins. Keeping this helper
|
|
193
|
+
* internal (not exported) lets the plugin-merger stay agnostic of
|
|
194
|
+
* which built-in trackers exist.
|
|
195
|
+
*/
|
|
196
|
+
function composeBuiltin(baseTracker, extras) {
|
|
197
|
+
if (!extras || Object.keys(extras).length === 0)
|
|
198
|
+
return baseTracker;
|
|
199
|
+
const merged = {};
|
|
200
|
+
for (const [basename, mod] of Object.entries(baseTracker.modifiers)) {
|
|
201
|
+
merged[basename] = Array.isArray(mod) ? [...mod] : mod;
|
|
202
|
+
}
|
|
203
|
+
for (const [basename, mods] of Object.entries(extras)) {
|
|
204
|
+
const existing = merged[basename];
|
|
205
|
+
const extrasTyped = mods;
|
|
206
|
+
if (existing === undefined) {
|
|
207
|
+
merged[basename] =
|
|
208
|
+
extrasTyped.length === 1 ? extrasTyped[0] : [...extrasTyped];
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const existingList = Array.isArray(existing)
|
|
212
|
+
? existing
|
|
213
|
+
: [existing];
|
|
214
|
+
merged[basename] = [...existingList, ...extrasTyped];
|
|
215
|
+
}
|
|
216
|
+
return { ...baseTracker, modifiers: merged };
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Prepare bash state for every rule to share: parse once, extract +
|
|
220
|
+
* expand wrappers once, walk trackers once, stringify each ref once.
|
|
221
|
+
*
|
|
222
|
+
* Also runs the walker-level speculative-entry synthesis pass and
|
|
223
|
+
* merges its output into each ref's walkerState under the reserved
|
|
224
|
+
* `events` key. The built-in `when.happened` predicate consults
|
|
225
|
+
* `ctx.walkerState.events[customType]` to unify real + speculative
|
|
226
|
+
* entries via timestamp ordering (see {@link evaluateHappened}).
|
|
227
|
+
*/
|
|
228
|
+
function prepareBashState(command, sessionCwd, trackers, observers) {
|
|
229
|
+
const script = parseBash(command);
|
|
230
|
+
const extracted = extractAllCommandsFromAST(script, command);
|
|
231
|
+
const { commands: refs } = expandWrapperCommands(extracted);
|
|
232
|
+
const walkResult = walk(script, { cwd: sessionCwd }, trackers, refs);
|
|
233
|
+
const speculativeEvents = synthesizeSpeculativeEntries(refs, observers);
|
|
234
|
+
return refs.map((ref) => {
|
|
235
|
+
const trackerState = walkResult.get(ref) ?? {
|
|
236
|
+
cwd: sessionCwd,
|
|
237
|
+
env: new Map(),
|
|
238
|
+
};
|
|
239
|
+
const events = speculativeEvents.get(ref) ?? {};
|
|
240
|
+
return {
|
|
241
|
+
ref,
|
|
242
|
+
text: refToText(ref),
|
|
243
|
+
basename: getBasename(ref),
|
|
244
|
+
// `node.suffix` is the quote-aware Word[] for the ref. Exposed
|
|
245
|
+
// to predicates via PredicateToolInput.args; the walker already
|
|
246
|
+
// parsed it so we just pass it through.
|
|
247
|
+
args: ref.node.suffix,
|
|
248
|
+
// `node.prefix` is unbash's AssignmentPrefix[] (shape:
|
|
249
|
+
// `{ text, name, value, ... }`). Project into Word[] so
|
|
250
|
+
// PredicateToolInput.envAssignments lines up with `.args` for
|
|
251
|
+
// plugin consumers — `.text` preserves the full "KEY=VALUE"
|
|
252
|
+
// source token (with quoting), and dynamic values like `A=$VAR`
|
|
253
|
+
// come through visibly in `.text` so callers can detect them.
|
|
254
|
+
envAssignments: ref.node.prefix.map((p) => ({
|
|
255
|
+
text: p.text,
|
|
256
|
+
value: p.text,
|
|
257
|
+
pos: p.pos,
|
|
258
|
+
end: p.end,
|
|
259
|
+
})),
|
|
260
|
+
// Merge tracker state with synthesized events so the built-in
|
|
261
|
+
// `happened` predicate can read `walkerState.events` without
|
|
262
|
+
// threading a separate context field. Trackers cannot name a
|
|
263
|
+
// dimension `"events"` — the plugin merger rejects that (see
|
|
264
|
+
// plugin-merger.ts). The merge is a shallow copy so the walker's
|
|
265
|
+
// state object stays untouched for future evaluations.
|
|
266
|
+
//
|
|
267
|
+
// The cast via `unknown` to `Readonly<WhenWalkerState>` is safe:
|
|
268
|
+
// buildEvaluator always registers `cwd` + `env` trackers, so every
|
|
269
|
+
// ref the walker yields carries both fields; the fallback literal
|
|
270
|
+
// above also supplies them. The schema interface's `readonly
|
|
271
|
+
// [key: string]: unknown` index signature tolerates the `events`
|
|
272
|
+
// key and any plugin-registered tracker slot. TypeScript's
|
|
273
|
+
// spread inference over `Record<string, unknown> | { cwd: string;
|
|
274
|
+
// env: Map<...> }` doesn't preserve the cwd/env shape through
|
|
275
|
+
// the spread, so the double cast is the minimum TS needs to
|
|
276
|
+
// accept a structure its inference widens away.
|
|
277
|
+
walkerState: {
|
|
278
|
+
...trackerState,
|
|
279
|
+
events,
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Compute the effective `noOverride` for a rule — rule-level explicit
|
|
286
|
+
* value wins, falling back to the config-level default (itself defaulted
|
|
287
|
+
* to fail-closed `true` per ADR).
|
|
288
|
+
*/
|
|
289
|
+
function effectiveNoOverride(rule, defaultNoOverride) {
|
|
290
|
+
return rule.noOverride ?? defaultNoOverride;
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Format the block reason shown to the agent. Appends an override hint
|
|
294
|
+
* ONLY when the rule is overridable — rules with
|
|
295
|
+
* `noOverride: true` (or the fail-closed default) omit it to avoid
|
|
296
|
+
* advertising a nonexistent escape hatch.
|
|
297
|
+
*
|
|
298
|
+
* Source-tagged (per ADR §11): `[steering:<rule-name>@<source>] …`
|
|
299
|
+
* where `<source>` is the originating plugin name for plugin-shipped
|
|
300
|
+
* rules, or `user` for rules declared directly in the user's
|
|
301
|
+
* SteeringConfig.rules.
|
|
302
|
+
*
|
|
303
|
+
* Rule.reason accepts both a static string and a {@link ReasonFn}
|
|
304
|
+
* (D3 in pr5-tier-b-shell-var-tracker-spec.md). Function reasons
|
|
305
|
+
* receive the same {@link PredicateContext} the predicates saw;
|
|
306
|
+
* async returns are awaited before prefixing. A reason function
|
|
307
|
+
* that throws or rejects is logged via `console.warn` and replaced
|
|
308
|
+
* with a fail-safe fallback string — the block verdict still fires.
|
|
309
|
+
* The exact fallback text is a stable contract rule authors can
|
|
310
|
+
* detect in tests.
|
|
311
|
+
*
|
|
312
|
+
* Tag→body separator is paragraph-aware: when the resolved body
|
|
313
|
+
* contains a `\n\n` paragraph break, the tag is rendered on its own
|
|
314
|
+
* line (`${tag}\n\n${body}`) so subsequent paragraphs don't orphan
|
|
315
|
+
* visually from the source-tag prefix. Single-paragraph bodies keep
|
|
316
|
+
* the legacy single-space layout (`${tag} ${body}`) — backward-
|
|
317
|
+
* compatible for every reason that was single-paragraph before the
|
|
318
|
+
* paragraph-aware rendering shipped. Trigger is `\n\n` or its CRLF
|
|
319
|
+
* equivalent `\r\n\r\n` (defensive against bodies imported from
|
|
320
|
+
* Windows line-ending sources — CRLF templating layers, hand-typed
|
|
321
|
+
* Windows-IDE strings); a single `\n` inside an otherwise single-
|
|
322
|
+
* paragraph body keeps the single-space layout. The emitted
|
|
323
|
+
* separator is always normalized to `\n\n` regardless of which form
|
|
324
|
+
* triggered it.
|
|
325
|
+
*
|
|
326
|
+
* Body→override-hint separator mirrors the same paragraph-aware
|
|
327
|
+
* separator. Single-paragraph bodies keep the single-space prefix
|
|
328
|
+
* on the override hint (byte-identical to the pre-paragraph-aware
|
|
329
|
+
* rendering); multi-paragraph bodies promote the override hint to
|
|
330
|
+
* its own paragraph (`${body}\n\n${hint}`) so the safety paragraph
|
|
331
|
+
* stays visually standalone rather than running on into an inline
|
|
332
|
+
* "To override" sentence. Mirror docs on {@link Rule.reason}.
|
|
333
|
+
*/
|
|
334
|
+
async function formatReason(rule, tool, noOverride, source, ctx) {
|
|
335
|
+
const tag = `[steering:${rule.name}@${source}]`;
|
|
336
|
+
const body = await resolveReasonBody(rule, source, ctx);
|
|
337
|
+
// Paragraph-aware tag separator — see function-level JSDoc for the
|
|
338
|
+
// contract; this line implements the trigger detection.
|
|
339
|
+
const multiPara = body.includes("\n\n") || body.includes("\r\n\r\n");
|
|
340
|
+
const separator = multiPara ? "\n\n" : " ";
|
|
341
|
+
if (noOverride)
|
|
342
|
+
return `${tag}${separator}${body}`;
|
|
343
|
+
const leader = tool === "bash" ? "#" : "//";
|
|
344
|
+
const hint = `To override, include a comment: ` +
|
|
345
|
+
`\`${leader} steering-override: ${rule.name} — <reason>\`.`;
|
|
346
|
+
return `${tag}${separator}${body}${separator}${hint}`;
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Resolve the string body of a rule's reason field. Handles both
|
|
350
|
+
* variants of the discriminated union on {@link Rule.reason}:
|
|
351
|
+
*
|
|
352
|
+
* - `string` — returned as-is.
|
|
353
|
+
* - `ReasonFn` — invoked with `ctx`, awaited, returned. A
|
|
354
|
+
* synchronous throw or rejected promise is
|
|
355
|
+
* caught, logged to `console.warn` with the
|
|
356
|
+
* rule name + source prefix + error message
|
|
357
|
+
* + stack, and replaced with the fail-safe
|
|
358
|
+
* fallback body `(reason failed to format;
|
|
359
|
+
* see log)`. The wrapping in
|
|
360
|
+
* {@link formatReason} still adds the source
|
|
361
|
+
* tag, so the agent sees
|
|
362
|
+
* `[steering:<rule>@<source>] (reason failed
|
|
363
|
+
* to format; see log)` — an unambiguous
|
|
364
|
+
* signal of a broken reason fn that still
|
|
365
|
+
* doesn't leak the error message.
|
|
366
|
+
*
|
|
367
|
+
* The fallback behavior is part of the public contract per spec
|
|
368
|
+
* D3: a rule author CAN assert the exact text (e.g. in a test
|
|
369
|
+
* asserting the engine keeps the block verdict alive when the
|
|
370
|
+
* reason function intentionally throws as a smoke-test).
|
|
371
|
+
*/
|
|
372
|
+
async function resolveReasonBody(rule, source, ctx) {
|
|
373
|
+
if (typeof rule.reason === "string")
|
|
374
|
+
return rule.reason;
|
|
375
|
+
try {
|
|
376
|
+
return await rule.reason(ctx);
|
|
377
|
+
}
|
|
378
|
+
catch (err) {
|
|
379
|
+
const msg = err instanceof Error ? `${err.message}\n${err.stack ?? ""}` : String(err);
|
|
380
|
+
console.warn(`[pi-steering] Rule "${rule.name}"@${source}: reason function threw: ${msg}`);
|
|
381
|
+
return "(reason failed to format; see log)";
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Run a rule's predicate chain (pattern → requires → unless → when).
|
|
386
|
+
* Returns the built {@link PredicateContext} when every predicate
|
|
387
|
+
* passes (rule fires), or `null` when the chain short-circuits to
|
|
388
|
+
* "no-fire" — **either** because a predicate legitimately rejected
|
|
389
|
+
* the candidate, **or** because a predicate threw.
|
|
390
|
+
*
|
|
391
|
+
* Throws are the S1 hardening: a predicate function (built-in or
|
|
392
|
+
* plugin-supplied) that throws synchronously or rejects asynchronously
|
|
393
|
+
* gets its error logged with the rule name + source and the rule is
|
|
394
|
+
* treated as NOT firing. Evaluation continues with the next rule.
|
|
395
|
+
*
|
|
396
|
+
* Why "does not fire" (vs "block" / "abort the whole evaluate"):
|
|
397
|
+
* - Mirrors the observer-dispatcher's per-observer isolation —
|
|
398
|
+
* one broken predicate must not poison the rest of the rule list.
|
|
399
|
+
* - A buggy predicate blocking everything would be worse UX than
|
|
400
|
+
* a buggy predicate silently failing — the block reason would
|
|
401
|
+
* leak the raw error message to the LLM (the pre-hardening
|
|
402
|
+
* behaviour). Top-level engine-throws still fail CLOSED; see
|
|
403
|
+
* {@link evaluateEvent}.
|
|
404
|
+
*/
|
|
405
|
+
async function runPredicateChain(rule, cand, shared) {
|
|
406
|
+
const source = shared.ruleSources.get(rule) ?? "user";
|
|
407
|
+
try {
|
|
408
|
+
// Pattern-miss is the common case; exit before allocating ctx.
|
|
409
|
+
if (!matchesPattern(rule.pattern, cand.target))
|
|
410
|
+
return null;
|
|
411
|
+
const ctx = {
|
|
412
|
+
cwd: cand.cwd,
|
|
413
|
+
tool: cand.tool,
|
|
414
|
+
input: cand.input,
|
|
415
|
+
agentLoopIndex: shared.agentLoopIndex,
|
|
416
|
+
exec: shared.exec,
|
|
417
|
+
appendEntry: shared.appendEntry,
|
|
418
|
+
findEntries: shared.findEntries,
|
|
419
|
+
...(cand.walkerState !== undefined
|
|
420
|
+
? { walkerState: cand.walkerState }
|
|
421
|
+
: {}),
|
|
422
|
+
};
|
|
423
|
+
if (rule.requires !== undefined) {
|
|
424
|
+
const ok = await matchesPatternOrFn(rule.requires, cand.target, ctx);
|
|
425
|
+
if (!ok)
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
if (rule.unless !== undefined) {
|
|
429
|
+
const ok = await matchesPatternOrFn(rule.unless, cand.target, ctx);
|
|
430
|
+
if (ok)
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
const whenOk = await evaluateWhen(rule.when, { cwd: cand.cwd }, ctx, shared.predicates, rule.name, source);
|
|
434
|
+
if (!whenOk)
|
|
435
|
+
return null;
|
|
436
|
+
return ctx;
|
|
437
|
+
}
|
|
438
|
+
catch (err) {
|
|
439
|
+
console.warn(`[pi-steering] predicate threw for rule "${rule.name}"@${source}: ${formatError(err)}`);
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Evaluate one candidate against one rule. This is the single pipeline
|
|
445
|
+
* every tool funnels through — differences between bash, write, and
|
|
446
|
+
* edit live entirely in the {@link Candidate} input.
|
|
447
|
+
*
|
|
448
|
+
* Evaluation order (short-circuits on first failure):
|
|
449
|
+
*
|
|
450
|
+
* 1. `pattern` — required; if no match we exit before allocating
|
|
451
|
+
* the predicate context.
|
|
452
|
+
* 2. `requires` — optional AND.
|
|
453
|
+
* 3. `unless` — optional exemption.
|
|
454
|
+
* 4. `when` — clause tree (`cwd`, `not`, `condition`, plugin
|
|
455
|
+
* predicates).
|
|
456
|
+
*
|
|
457
|
+
* All four steps are wrapped in a try/catch via
|
|
458
|
+
* {@link runPredicateChain} — a throw is logged and treated as "rule
|
|
459
|
+
* did not fire". That way a buggy predicate neither short-circuits the
|
|
460
|
+
* whole rule list (a broken guardrail rule silently poisoning the
|
|
461
|
+
* rest) nor leaks its raw `error.message` back to the agent via a
|
|
462
|
+
* pi-level error tool_result.
|
|
463
|
+
*
|
|
464
|
+
* On rule fire, check for an override comment addressing the rule by
|
|
465
|
+
* name (unless the rule opts out of overrides). An accepted override
|
|
466
|
+
* logs a `steering-override` audit entry and returns `"overridden"`.
|
|
467
|
+
*/
|
|
468
|
+
async function evaluateCandidate(rule, cand, shared) {
|
|
469
|
+
const ctx = await runPredicateChain(rule, cand, shared);
|
|
470
|
+
if (ctx === null)
|
|
471
|
+
return "no-fire";
|
|
472
|
+
// Rule fires. Check for override (if allowed) before committing to
|
|
473
|
+
// blocking.
|
|
474
|
+
const noOverride = effectiveNoOverride(rule, shared.defaultNoOverride);
|
|
475
|
+
if (!noOverride) {
|
|
476
|
+
const reason = extractOverride(cand.overrideCarrier, rule.name);
|
|
477
|
+
if (reason !== null) {
|
|
478
|
+
// Go through the wrapped `shared.appendEntry` so the
|
|
479
|
+
// `_agentLoopIndex` auto-tag lands on the audit entry. Rules
|
|
480
|
+
// using `when.happened: { event: "steering-override", in:
|
|
481
|
+
// "agent_loop" }` rely on the tag to filter overrides by the
|
|
482
|
+
// current loop; a direct `host.appendEntry` here would bypass
|
|
483
|
+
// the wrapper and leave the entry invisible to that predicate.
|
|
484
|
+
shared.appendEntry("steering-override", {
|
|
485
|
+
rule: rule.name,
|
|
486
|
+
reason,
|
|
487
|
+
...cand.overrideEntryExtras,
|
|
488
|
+
timestamp: new Date().toISOString(),
|
|
489
|
+
});
|
|
490
|
+
return "overridden";
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
// Block is going to fire. Run the optional side-effect hook before
|
|
494
|
+
// returning the verdict — rules using `onFire` to self-mark (e.g.
|
|
495
|
+
// "write a session entry so my next attempt this agent loop passes")
|
|
496
|
+
// need the write to land before the agent sees the block. Override
|
|
497
|
+
// paths above already returned, so onFire is skipped when the rule
|
|
498
|
+
// was overridden; fail-closed defaults with no override comment fall
|
|
499
|
+
// through here normally.
|
|
500
|
+
//
|
|
501
|
+
// Fail-closed semantics on onFire errors: a sync throw or rejected
|
|
502
|
+
// promise is logged and SWALLOWED — the block still returns. The
|
|
503
|
+
// block decision already passed every predicate; a broken
|
|
504
|
+
// best-effort side effect must not silently invalidate it. Mirrors
|
|
505
|
+
// the observer-dispatcher's per-observer try/catch (observers are
|
|
506
|
+
// isolated for the same reason).
|
|
507
|
+
if (rule.onFire) {
|
|
508
|
+
try {
|
|
509
|
+
await rule.onFire(ctx);
|
|
510
|
+
}
|
|
511
|
+
catch (err) {
|
|
512
|
+
console.warn(`[pi-steering] onFire for rule "${rule.name}" threw: ${formatError(err)}`);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
return {
|
|
516
|
+
block: true,
|
|
517
|
+
reason: await formatReason(rule, cand.tool, noOverride, shared.ruleSources.get(rule) ?? "user", ctx),
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
async function evaluateEvent(event, ctx, agentLoopIndex, rules, trackers, predicates, host, defaultNoOverride, ruleSources, allObservers) {
|
|
521
|
+
// Top-level fail-closed wrap (S1). If the engine's own scaffolding
|
|
522
|
+
// throws — parse errors, walker bugs, corrupted session JSONL, etc.
|
|
523
|
+
// — we block the tool AS A SAFETY MEASURE and tag the reason so the
|
|
524
|
+
// agent sees it came from the engine, not from a rule or plugin.
|
|
525
|
+
// Per-predicate throws are handled one level down in
|
|
526
|
+
// {@link runPredicateChain} (treated as "rule does not fire"); this
|
|
527
|
+
// outer wrap only catches throws OUTSIDE the per-rule try/catch.
|
|
528
|
+
try {
|
|
529
|
+
return await evaluateEventInner(event, ctx, agentLoopIndex, rules, trackers, predicates, host, defaultNoOverride, ruleSources, allObservers);
|
|
530
|
+
}
|
|
531
|
+
catch (err) {
|
|
532
|
+
console.error(`[pi-steering] steering engine threw: ${formatError(err)}`);
|
|
533
|
+
return {
|
|
534
|
+
block: true,
|
|
535
|
+
reason: "[steering:engine@internal] steering engine error; " +
|
|
536
|
+
"tool blocked as a safety measure",
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
async function evaluateEventInner(event, ctx, agentLoopIndex, rules, trackers, predicates, host, defaultNoOverride, ruleSources, allObservers) {
|
|
541
|
+
// Shared per-call closures: exec memoized by (cmd, args, cwd);
|
|
542
|
+
// findEntries reads the current session JSONL on demand; appendEntry
|
|
543
|
+
// auto-tags writes with `_agentLoopIndex` so rules using
|
|
544
|
+
// `when.happened` can filter by agent-loop scope.
|
|
545
|
+
//
|
|
546
|
+
// findEntries + appendEntry share a session-entry cache so a write
|
|
547
|
+
// performed by an earlier rule's onFire (or by the override-audit
|
|
548
|
+
// path) invalidates the cached read — later rules' when.happened
|
|
549
|
+
// predicates see the fresh write instead of a stale snapshot
|
|
550
|
+
// (S2/E1). The evaluator itself doesn't interleave writes with reads,
|
|
551
|
+
// but onFire + override-audit do.
|
|
552
|
+
const exec = createExecCache(host, ctx.cwd);
|
|
553
|
+
const entryCache = createSessionEntryCache();
|
|
554
|
+
const findEntries = createFindEntries(ctx, entryCache);
|
|
555
|
+
const appendEntry = createAppendEntry(host, agentLoopIndex, entryCache);
|
|
556
|
+
const shared = {
|
|
557
|
+
agentLoopIndex,
|
|
558
|
+
predicates,
|
|
559
|
+
exec,
|
|
560
|
+
appendEntry,
|
|
561
|
+
findEntries,
|
|
562
|
+
host,
|
|
563
|
+
defaultNoOverride,
|
|
564
|
+
ruleSources,
|
|
565
|
+
};
|
|
566
|
+
// Bash state is lazy: non-bash rules don't pay for parse / walk.
|
|
567
|
+
let bashState = null;
|
|
568
|
+
const bashEvent = isToolCallEventType("bash", event) ? event : null;
|
|
569
|
+
// Edit events share `allNewText` across every field="content" rule.
|
|
570
|
+
// Computed lazily on the first edit rule so a config with only bash /
|
|
571
|
+
// write rules doesn't pay the join cost. `null` sentinel is safe
|
|
572
|
+
// because `edits` is always a non-null array on edit events.
|
|
573
|
+
const editEvent = isToolCallEventType("edit", event) ? event : null;
|
|
574
|
+
let editAllNewText = null;
|
|
575
|
+
for (const rule of rules) {
|
|
576
|
+
if (rule.tool !== event.toolName)
|
|
577
|
+
continue;
|
|
578
|
+
if (rule.tool === "bash") {
|
|
579
|
+
if (!bashEvent)
|
|
580
|
+
continue;
|
|
581
|
+
if (bashState === null) {
|
|
582
|
+
bashState = prepareBashState(bashEvent.input.command, ctx.cwd, trackers, allObservers);
|
|
583
|
+
}
|
|
584
|
+
const result = await evaluateBashRule(rule, bashEvent.input.command, bashState, shared);
|
|
585
|
+
if (result !== undefined)
|
|
586
|
+
return result;
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
if (rule.tool === "write" && isToolCallEventType("write", event)) {
|
|
590
|
+
const target = rule.field === "path" ? event.input.path : event.input.content;
|
|
591
|
+
const result = await evaluateWriteEditRule(rule, {
|
|
592
|
+
tool: "write",
|
|
593
|
+
path: event.input.path,
|
|
594
|
+
content: event.input.content,
|
|
595
|
+
// Shell env assignments don't apply to file-surface tools;
|
|
596
|
+
// shape as `[]` rather than `undefined` so plugin authors
|
|
597
|
+
// can treat the field uniformly across tools.
|
|
598
|
+
envAssignments: [],
|
|
599
|
+
}, target,
|
|
600
|
+
// override-comment scanned against content (the natural
|
|
601
|
+
// carrier for write override comments — v1 parity).
|
|
602
|
+
event.input.content, event.input.path, ctx.cwd, shared);
|
|
603
|
+
if (result !== undefined)
|
|
604
|
+
return result;
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
if (rule.tool === "edit" && editEvent) {
|
|
608
|
+
// Joined newText is needed as override carrier for EVERY edit
|
|
609
|
+
// rule plus as `target` for field="content" rules. Compute once
|
|
610
|
+
// per tool_call on the first edit rule, reuse for the rest.
|
|
611
|
+
if (editAllNewText === null) {
|
|
612
|
+
editAllNewText = editEvent.input.edits.map((e) => e.newText).join("\n");
|
|
613
|
+
}
|
|
614
|
+
const target = rule.field === "path" ? editEvent.input.path : editAllNewText;
|
|
615
|
+
const result = await evaluateWriteEditRule(rule, {
|
|
616
|
+
tool: "edit",
|
|
617
|
+
path: editEvent.input.path,
|
|
618
|
+
edits: editEvent.input.edits,
|
|
619
|
+
// See the write branch above: `[]` for uniform shape.
|
|
620
|
+
envAssignments: [],
|
|
621
|
+
}, target, editAllNewText, editEvent.input.path, ctx.cwd, shared);
|
|
622
|
+
if (result !== undefined)
|
|
623
|
+
return result;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
return undefined;
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Per-rule bash evaluation. Iterates every extracted command ref as
|
|
630
|
+
* a {@link Candidate}. The first ref that fires the rule (pattern +
|
|
631
|
+
* requires + unless + when) decides the verdict. Per v1 semantics, an
|
|
632
|
+
* accepted override covers the whole tool_call — we stop scanning
|
|
633
|
+
* further refs and hand control back to the caller.
|
|
634
|
+
*/
|
|
635
|
+
async function evaluateBashRule(rule, rawCommand, state, shared) {
|
|
636
|
+
for (const refState of state) {
|
|
637
|
+
const cand = {
|
|
638
|
+
target: refState.text,
|
|
639
|
+
cwd: typeof refState.walkerState["cwd"] === "string"
|
|
640
|
+
? refState.walkerState["cwd"]
|
|
641
|
+
: "unknown",
|
|
642
|
+
input: {
|
|
643
|
+
tool: "bash",
|
|
644
|
+
command: refState.text,
|
|
645
|
+
basename: refState.basename,
|
|
646
|
+
args: refState.args,
|
|
647
|
+
envAssignments: refState.envAssignments,
|
|
648
|
+
},
|
|
649
|
+
overrideCarrier: rawCommand,
|
|
650
|
+
tool: "bash",
|
|
651
|
+
overrideEntryExtras: { command: rawCommand },
|
|
652
|
+
walkerState: refState.walkerState,
|
|
653
|
+
};
|
|
654
|
+
const r = await evaluateCandidate(rule, cand, shared);
|
|
655
|
+
if (r === "no-fire")
|
|
656
|
+
continue;
|
|
657
|
+
if (r === "overridden")
|
|
658
|
+
return undefined; // v1: override covers whole tool_call
|
|
659
|
+
return r;
|
|
660
|
+
}
|
|
661
|
+
return undefined;
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Per-rule write / edit evaluation. Produces a single {@link Candidate}
|
|
665
|
+
* and defers to {@link evaluateCandidate}.
|
|
666
|
+
*
|
|
667
|
+
* `target` is the pre-resolved string the rule's pattern tests against
|
|
668
|
+
* — the caller computes it once per rule (reading `path` or the joined
|
|
669
|
+
* `newText`), which lets edit tool_calls share the join across every
|
|
670
|
+
* field="content" rule. `overrideCarrier` is the text scanned for
|
|
671
|
+
* override comments (per v1 parity, content / joined newText even for
|
|
672
|
+
* field="path" rules).
|
|
673
|
+
*/
|
|
674
|
+
async function evaluateWriteEditRule(rule, input, target, overrideCarrier, path, sessionCwd, shared) {
|
|
675
|
+
const cand = {
|
|
676
|
+
target,
|
|
677
|
+
cwd: sessionCwd,
|
|
678
|
+
input,
|
|
679
|
+
overrideCarrier,
|
|
680
|
+
tool: rule.tool,
|
|
681
|
+
overrideEntryExtras: { path },
|
|
682
|
+
};
|
|
683
|
+
const r = await evaluateCandidate(rule, cand, shared);
|
|
684
|
+
if (r === "no-fire" || r === "overridden")
|
|
685
|
+
return undefined;
|
|
686
|
+
return r;
|
|
687
|
+
}
|
|
688
|
+
// ---------------------------------------------------------------------------
|
|
689
|
+
// Error formatting
|
|
690
|
+
// ---------------------------------------------------------------------------
|
|
691
|
+
/**
|
|
692
|
+
* Format an unknown thrown value for a warning log. Shared across the
|
|
693
|
+
* three places the evaluator catches throws:
|
|
694
|
+
*
|
|
695
|
+
* - per-predicate try/catch in {@link runPredicateChain} (S1).
|
|
696
|
+
* - per-rule `onFire` try/catch in {@link evaluateCandidate}.
|
|
697
|
+
* - top-level engine try/catch in {@link evaluateEvent}.
|
|
698
|
+
*
|
|
699
|
+
* Mirrors the observer-dispatcher's `formatError` so the log shape
|
|
700
|
+
* stays consistent across the two hook surfaces: `message\nstack` for
|
|
701
|
+
* proper Errors, best-effort JSON otherwise, falling through to
|
|
702
|
+
* `String(err)`.
|
|
703
|
+
*/
|
|
704
|
+
function formatError(err) {
|
|
705
|
+
if (err instanceof Error)
|
|
706
|
+
return `${err.message}\n${err.stack ?? ""}`;
|
|
707
|
+
try {
|
|
708
|
+
return JSON.stringify(err);
|
|
709
|
+
}
|
|
710
|
+
catch {
|
|
711
|
+
return String(err);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
//# sourceMappingURL=evaluator.js.map
|