@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,849 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Part of pi-steering.
|
|
3
|
+
/**
|
|
4
|
+
* Predicate evaluators for the v2 engine.
|
|
5
|
+
*
|
|
6
|
+
* Three public entry points:
|
|
7
|
+
*
|
|
8
|
+
* - {@link matchesPatternOrFn} — resolves `pattern` / `requires` /
|
|
9
|
+
* `unless` values against a target
|
|
10
|
+
* string.
|
|
11
|
+
* - {@link evaluateWhen} — walks a {@link TopLevelWhenClause} tree,
|
|
12
|
+
* dispatching built-in (`cwd`, `not`,
|
|
13
|
+
* `condition`) + plugin-registered
|
|
14
|
+
* predicates.
|
|
15
|
+
* - {@link UnknownPredicateError} — thrown when a {@link TopLevelWhenClause} names a
|
|
16
|
+
* predicate nobody registered. Kept as
|
|
17
|
+
* a named error so callers can catch
|
|
18
|
+
* it by type; the message includes the
|
|
19
|
+
* offending key.
|
|
20
|
+
*
|
|
21
|
+
* The walker's `cwdTracker.unknown` sentinel is `"unknown"`. That's the
|
|
22
|
+
* string we compare against for `onUnknown` policy application on the
|
|
23
|
+
* built-in `cwd` predicate. Plugin-registered trackers emit their own
|
|
24
|
+
* unknown sentinels; handling those is the plugin handler's job.
|
|
25
|
+
*/
|
|
26
|
+
import { isPattern } from "../internal/pattern-utils.js";
|
|
27
|
+
import { AGENT_LOOP_INDEX_KEY } from "./context.js";
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Reserved predicate keys (runtime mirror of `ReservedPredicateKey`)
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
/**
|
|
32
|
+
* Runtime list of predicate keys that plugins are NOT allowed to
|
|
33
|
+
* register. Mirrors the type-level {@link ReservedPredicateKey} from
|
|
34
|
+
* `schema.ts`. Adding a new modifier to {@link PredicateModifiers} OR a
|
|
35
|
+
* new operator field requires updating both this list and the matching
|
|
36
|
+
* type union; the {@link reservedPredicateKeysCoverReservedTypes}
|
|
37
|
+
* sync-pinning test in `evaluator.test.ts` fails when the two drift.
|
|
38
|
+
*
|
|
39
|
+
* The engine (in the plugin merger) throws at plugin-registration time
|
|
40
|
+
* if a plugin attempts a reserved key, with a concrete error message
|
|
41
|
+
* pointing the plugin author at the collision and suggesting an
|
|
42
|
+
* alternative name.
|
|
43
|
+
*/
|
|
44
|
+
export const RESERVED_PREDICATE_KEYS = [
|
|
45
|
+
// Operator fields (must mirror schema.ts's `OperatorField` union).
|
|
46
|
+
"not",
|
|
47
|
+
// Modifier keys (must mirror `keyof PredicateModifiers`).
|
|
48
|
+
"onUnknown",
|
|
49
|
+
];
|
|
50
|
+
const _RESERVED_PREDICATE_KEYS_COVERS_TYPE = true;
|
|
51
|
+
void _RESERVED_PREDICATE_KEYS_COVERS_TYPE;
|
|
52
|
+
/**
|
|
53
|
+
* Whether a string key is reserved (cannot be used as a plugin
|
|
54
|
+
* predicate name). Used by the plugin merger and by
|
|
55
|
+
* {@link validateWhenClauseShape} when computing the leaf-key set of
|
|
56
|
+
* a `not:` block.
|
|
57
|
+
*/
|
|
58
|
+
export function isReservedPredicateKey(key) {
|
|
59
|
+
return RESERVED_PREDICATE_KEYS.includes(key);
|
|
60
|
+
}
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// When-clause shape validation (config-resolve time)
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
/**
|
|
65
|
+
* Throws if a `when:` or `not:` block contains no predicate-leaf keys
|
|
66
|
+
* after stripping modifier keys.
|
|
67
|
+
*
|
|
68
|
+
* Catches three foot-guns at config-resolve time so the engine never
|
|
69
|
+
* has to silently skip a malformed clause:
|
|
70
|
+
* - `when: {}` — zero keys.
|
|
71
|
+
* - `not: { onUnknown: "block" }` — one key, but it's a modifier; no
|
|
72
|
+
* leaves means the block has nothing to evaluate.
|
|
73
|
+
* - `when: { not: { not: ... } }` — nested `not:` inside a not-block,
|
|
74
|
+
* authored via JSON load or `as any` escape hatch (the type-level
|
|
75
|
+
* ban via {@link TopLevelWhenClauseNoRecurse} catches authoring-
|
|
76
|
+
* time mistakes; the same recursion catches the JSON / `as any`
|
|
77
|
+
* escape hatch).
|
|
78
|
+
*
|
|
79
|
+
* The `not:` operator field itself counts as a leaf at the outer
|
|
80
|
+
* `when:` level (it produces a verdict via Kleene composition of the
|
|
81
|
+
* inner not-block); only modifier keys are stripped. Built-in
|
|
82
|
+
* non-registry keys (`condition`, `happened`, `cwd`) count as leaves;
|
|
83
|
+
* plugin-registered predicates count as leaves regardless of whether
|
|
84
|
+
* the plugin is currently loaded — the unknown-predicate check fires
|
|
85
|
+
* later via {@link UnknownPredicateError}.
|
|
86
|
+
*
|
|
87
|
+
* Recurses into the `not:` block to enforce the same shape there.
|
|
88
|
+
*
|
|
89
|
+
* `path` describes the call site for error messages, e.g.
|
|
90
|
+
* `'rule "no-main-commit".when'` or `'rule "no-git-worktree".when.not'`.
|
|
91
|
+
*/
|
|
92
|
+
export function validateWhenClauseShape(block, path) {
|
|
93
|
+
if (block === undefined)
|
|
94
|
+
return;
|
|
95
|
+
let leafKeys = 0;
|
|
96
|
+
for (const key of Object.keys(block)) {
|
|
97
|
+
const v = block[key];
|
|
98
|
+
if (v === undefined)
|
|
99
|
+
continue;
|
|
100
|
+
// Strip modifier keys only — the operator field `not:` produces a
|
|
101
|
+
// verdict via Kleene composition of the inner not-block, so it
|
|
102
|
+
// counts as a leaf for the outer level's leaf-count.
|
|
103
|
+
if (isModifierKey(key))
|
|
104
|
+
continue;
|
|
105
|
+
leafKeys += 1;
|
|
106
|
+
}
|
|
107
|
+
if (leafKeys === 0) {
|
|
108
|
+
throw new Error(`[pi-steering] ${path} contains no predicate leaves; ` +
|
|
109
|
+
`a clause must contain at least one predicate (cwd:, branch:, ` +
|
|
110
|
+
`commitsAhead:, condition:, happened:, not:, etc.). Modifier keys ` +
|
|
111
|
+
`(${MODIFIER_KEYS.join(", ")}) alone are not enough — add a leaf ` +
|
|
112
|
+
`or remove the empty clause.`);
|
|
113
|
+
}
|
|
114
|
+
// Recurse into the `not:` block. `condition:` is a function leaf,
|
|
115
|
+
// no recursion. Other plugin keys can carry nested objects (e.g.
|
|
116
|
+
// the built-in `happened` shape) but those aren't when-clauses, so
|
|
117
|
+
// recursion is scoped to the `not:` operator only.
|
|
118
|
+
const notBlock = block.not;
|
|
119
|
+
if (notBlock !== undefined &&
|
|
120
|
+
typeof notBlock === "object" &&
|
|
121
|
+
notBlock !== null) {
|
|
122
|
+
// Reject nested `not:` at runtime. The type-level ban
|
|
123
|
+
// ({@link TopLevelWhenClauseNoRecurse} omits the `not?:` field)
|
|
124
|
+
// catches authoring-time mistakes, but JSON-loaded configs and
|
|
125
|
+
// `as any` escape hatches can author the shape; without this
|
|
126
|
+
// guard the engine's reserved-key skip would silently drop the
|
|
127
|
+
// inner `not:` (zero verdicts → vacuous true → outer not-flip =
|
|
128
|
+
// false → rule never fires).
|
|
129
|
+
if ("not" in notBlock &&
|
|
130
|
+
notBlock.not !== undefined) {
|
|
131
|
+
throw new Error(`[pi-steering] '${path}.not' contains a nested 'not:' key. ` +
|
|
132
|
+
`Use a single 'not:' wrapper; nested 'not: { not: ... }' is ` +
|
|
133
|
+
`semantically equivalent to the unwrapped form and is forbidden ` +
|
|
134
|
+
`by the schema.`);
|
|
135
|
+
}
|
|
136
|
+
validateWhenClauseShape(notBlock, `${path}.not`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Modifier-only subset of {@link RESERVED_PREDICATE_KEYS} — used by
|
|
141
|
+
* {@link validateWhenClauseShape} to strip modifiers when counting
|
|
142
|
+
* leaves. Operator fields (currently `"not"`) are NOT modifiers; they
|
|
143
|
+
* produce verdicts and count as leaves.
|
|
144
|
+
*
|
|
145
|
+
* Type-level coverage assertion (mirrors the
|
|
146
|
+
* {@link _RESERVED_PREDICATE_KEYS_COVERS_TYPE} pattern): the
|
|
147
|
+
* `satisfies readonly (keyof PredicateModifiers)[]` clause pins each
|
|
148
|
+
* entry to a real modifier key, AND the
|
|
149
|
+
* {@link _MODIFIER_KEYS_COVERS_TYPE} constant fails compilation if a
|
|
150
|
+
* future modifier (e.g. a hypothetical v0.2 `priority?: number`) is
|
|
151
|
+
* added to {@link PredicateModifiers} without updating this list.
|
|
152
|
+
* Without the lockstep check, the new modifier would be counted as a
|
|
153
|
+
* leaf by {@link validateWhenClauseShape}, masking empty-clause
|
|
154
|
+
* configs that are now "only modifiers, no real leaves."
|
|
155
|
+
*/
|
|
156
|
+
export const MODIFIER_KEYS = [
|
|
157
|
+
"onUnknown",
|
|
158
|
+
];
|
|
159
|
+
const _MODIFIER_KEYS_COVERS_TYPE = true;
|
|
160
|
+
void _MODIFIER_KEYS_COVERS_TYPE;
|
|
161
|
+
function isModifierKey(key) {
|
|
162
|
+
return MODIFIER_KEYS.includes(key);
|
|
163
|
+
}
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// Pattern / PredicateFn resolution
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
/**
|
|
168
|
+
* Regex-compile cache: reuses the same RegExp object for the same string
|
|
169
|
+
* source. Rule patterns are typically long-lived; caching avoids
|
|
170
|
+
* recompilation on every tool_call while still being safe for ad-hoc
|
|
171
|
+
* patterns (weak in the worst case, Map in practice).
|
|
172
|
+
*
|
|
173
|
+
* Module-scoped so it lives across evaluator instances — same rule
|
|
174
|
+
* definition in two configs produces the same RegExp. Cheap enough
|
|
175
|
+
* we don't bother with eviction.
|
|
176
|
+
*/
|
|
177
|
+
const REGEX_CACHE = new Map();
|
|
178
|
+
function compileRegex(source) {
|
|
179
|
+
const hit = REGEX_CACHE.get(source);
|
|
180
|
+
if (hit !== undefined)
|
|
181
|
+
return hit;
|
|
182
|
+
const re = new RegExp(source);
|
|
183
|
+
REGEX_CACHE.set(source, re);
|
|
184
|
+
return re;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Match a string against a {@link Pattern} (string source or RegExp).
|
|
188
|
+
* Patterns are compiled once and cached; RegExps pass through.
|
|
189
|
+
*/
|
|
190
|
+
export function matchesPattern(pattern, target) {
|
|
191
|
+
if (pattern instanceof RegExp)
|
|
192
|
+
return pattern.test(target);
|
|
193
|
+
return compileRegex(pattern).test(target);
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Evaluate a rule-level predicate (`pattern`, `requires`, `unless`).
|
|
197
|
+
*
|
|
198
|
+
* Accepts the same union v1's `Rule` supported plus {@link PredicateFn}:
|
|
199
|
+
* - `string` / `RegExp` → pattern match against `target`.
|
|
200
|
+
* - `function` → call with `ctx`, coerce result to boolean.
|
|
201
|
+
*/
|
|
202
|
+
export async function matchesPatternOrFn(value, target, ctx) {
|
|
203
|
+
if (typeof value === "function") {
|
|
204
|
+
const r = await value(ctx);
|
|
205
|
+
return Boolean(r);
|
|
206
|
+
}
|
|
207
|
+
return matchesPattern(value, target);
|
|
208
|
+
}
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
// TopLevelWhenClause dispatch
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
/**
|
|
213
|
+
* Thrown when a {@link TopLevelWhenClause} references a predicate name that no
|
|
214
|
+
* plugin has registered. The error message includes the offending key
|
|
215
|
+
* so the source of the typo / missing plugin is clear at the site of
|
|
216
|
+
* the rule.
|
|
217
|
+
*
|
|
218
|
+
* Schema-level typo detection doesn't cover this because the
|
|
219
|
+
* `TopLevelWhenClause` mapped-type's index signature is deliberately
|
|
220
|
+
* loose (`unknown`) — per
|
|
221
|
+
* the ADR, plugin predicates can accept arbitrary arg shapes. The
|
|
222
|
+
* trade-off is that we surface the error at evaluation time instead of
|
|
223
|
+
* load time; the key-scoped message keeps that tolerable.
|
|
224
|
+
*/
|
|
225
|
+
export class UnknownPredicateError extends Error {
|
|
226
|
+
key;
|
|
227
|
+
constructor(key) {
|
|
228
|
+
super(`[pi-steering] unknown when.${key} predicate — ` +
|
|
229
|
+
`no plugin registered a handler for this key. ` +
|
|
230
|
+
`Check for typos, or add a plugin that provides "${key}".`);
|
|
231
|
+
this.name = "UnknownPredicateError";
|
|
232
|
+
this.key = key;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Built-in `when.cwd` predicate. Accepts shorthand `Pattern`,
|
|
237
|
+
* shorthand `Pattern[]` (OR-of-matches), or the object form
|
|
238
|
+
* `{ pattern: Pattern | Pattern[]; onUnknown? }`. Returns a trinary
|
|
239
|
+
* {@link PredicateVerdict}: `true` / `false` for definite matches
|
|
240
|
+
* against the walker-resolved cwd, or `"unknown"` when the walker's
|
|
241
|
+
* `cwdTracker` couldn't resolve the effective cwd statically (the
|
|
242
|
+
* cwd-tracker `"unknown"` sentinel).
|
|
243
|
+
*
|
|
244
|
+
* The `onUnknown:` modifier on the object form is NOT consumed here.
|
|
245
|
+
* Trinary unknown is surfaced to the caller; the leaf-trinary adapter
|
|
246
|
+
* (outer level) or the not-block evaluator (inner level) applies the
|
|
247
|
+
* `onUnknown:` policy uniformly across leaves.
|
|
248
|
+
*
|
|
249
|
+
* Array semantics: OR-of-matches (predicate matches when the resolved
|
|
250
|
+
* cwd matches ANY of the listed patterns). Empty arrays are invalid
|
|
251
|
+
* (returns `false`); arrays containing non-Pattern values are invalid
|
|
252
|
+
* (returns `false`). Asymmetry: a malformed non-array scalar (e.g.
|
|
253
|
+
* `cwd: 123`) keeps the pre-extension fail-CLOSED behavior — under
|
|
254
|
+
* unknown cwd the predicate surfaces `"unknown"`; under known cwd the
|
|
255
|
+
* trailing `matchesPattern` regex-coercion almost always falls through
|
|
256
|
+
* to `false`. See inline comments for the empirical regex-character-
|
|
257
|
+
* class rationale.
|
|
258
|
+
*
|
|
259
|
+
* Fast path: the common shorthand form `when.cwd: /regex/` (or a
|
|
260
|
+
* string pattern) is read directly — no normalization object
|
|
261
|
+
* allocated. Only the object form `{ pattern, onUnknown }` takes the
|
|
262
|
+
* slightly-slower path of reading the pattern field.
|
|
263
|
+
*/
|
|
264
|
+
function evaluateCwd(value, walkerCwd) {
|
|
265
|
+
// Shorthand Pattern form (string or RegExp).
|
|
266
|
+
if (typeof value === "string" || value instanceof RegExp) {
|
|
267
|
+
if (walkerCwd === "unknown")
|
|
268
|
+
return "unknown";
|
|
269
|
+
return matchesPattern(value, walkerCwd);
|
|
270
|
+
}
|
|
271
|
+
// Shorthand Pattern[] form (non-empty, all-Pattern).
|
|
272
|
+
if (Array.isArray(value) && value.every(isPattern)) {
|
|
273
|
+
if (value.length === 0)
|
|
274
|
+
return false; // empty array invalid → rule skips
|
|
275
|
+
if (walkerCwd === "unknown")
|
|
276
|
+
return "unknown";
|
|
277
|
+
return value.some((p) => matchesPattern(p, walkerCwd));
|
|
278
|
+
}
|
|
279
|
+
// Object form: { pattern: Pattern | Pattern[]; onUnknown? } — the
|
|
280
|
+
// `onUnknown:` modifier is consumed by the caller-side leaf adapter,
|
|
281
|
+
// not here. We surface trinary unknown uniformly.
|
|
282
|
+
if (value !== null &&
|
|
283
|
+
typeof value === "object" &&
|
|
284
|
+
"pattern" in value) {
|
|
285
|
+
const obj = value;
|
|
286
|
+
if (isPattern(obj.pattern)) {
|
|
287
|
+
if (walkerCwd === "unknown")
|
|
288
|
+
return "unknown";
|
|
289
|
+
return matchesPattern(obj.pattern, walkerCwd);
|
|
290
|
+
}
|
|
291
|
+
if (Array.isArray(obj.pattern) && obj.pattern.every(isPattern)) {
|
|
292
|
+
if (obj.pattern.length === 0)
|
|
293
|
+
return false;
|
|
294
|
+
if (walkerCwd === "unknown")
|
|
295
|
+
return "unknown";
|
|
296
|
+
return obj.pattern.some((p) => matchesPattern(p, walkerCwd));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
// Explicit fail-skip for array-shaped input that isn't all-Pattern.
|
|
300
|
+
// Without this, `cwd: [/foo/, 123]` falls through to the malformed
|
|
301
|
+
// shorthand path below — `new RegExp(String([/foo/, 123]))` compiles
|
|
302
|
+
// to `/\/foo\/,123/`, which only matches paths containing the
|
|
303
|
+
// literal substring `/foo/,123` (effectively skip under known cwd),
|
|
304
|
+
// but the unknown-cwd branch still surfaces unknown. Asymmetric with
|
|
305
|
+
// the gitPlugin sites' clean null-→-skip path; pin uniformly.
|
|
306
|
+
if (Array.isArray(value))
|
|
307
|
+
return false;
|
|
308
|
+
// Object that fell out of the object-form branch (e.g.
|
|
309
|
+
// `{ pattern: [/foo/, 123] }` or `{ pattern: 123 }`) — fail-skip
|
|
310
|
+
// uniformly with the array-shorthand and the gitPlugin sites'
|
|
311
|
+
// null-→-skip path. Without this guard, the trailing
|
|
312
|
+
// `matchesPattern(value as Pattern, walkerCwd)` would silently
|
|
313
|
+
// regex-coerce the malformed object via `String(obj)` →
|
|
314
|
+
// `"[object Object]"`, which JS parses as a single character class
|
|
315
|
+
// `/[object Object]/` matching any of {b, c, e, j, o, t, space, O}.
|
|
316
|
+
// Under known cwd that regex matches almost every real path — silent
|
|
317
|
+
// fail-OPEN-fire, masking the config error. The unknown-cwd branch
|
|
318
|
+
// surfacing unknown is also asymmetric with the shorthand-array
|
|
319
|
+
// malformed path that fail-skips uniformly. This guard makes
|
|
320
|
+
// object-form malformed input skip uniformly.
|
|
321
|
+
if (value !== null && typeof value === "object")
|
|
322
|
+
return false;
|
|
323
|
+
// Malformed non-array input — treat as fail-closed shorthand attempt
|
|
324
|
+
// (preserves existing pre-extension behavior for non-array malformed
|
|
325
|
+
// values: under unknown cwd, surface unknown; under known cwd,
|
|
326
|
+
// attempt a regex coercion which almost certainly produces `false`).
|
|
327
|
+
if (walkerCwd === "unknown")
|
|
328
|
+
return "unknown";
|
|
329
|
+
return matchesPattern(value, walkerCwd);
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Built-in `when.happened` predicate. Merges real session entries
|
|
333
|
+
* (from `ctx.findEntries`, scope-filtered) with speculative entries
|
|
334
|
+
* (from `ctx.walkerState.events[event]`, produced by the walker-level
|
|
335
|
+
* synthesis pass — see {@link synthesizeSpeculativeEntries}) and
|
|
336
|
+
* returns **true when the unified timeline says the event has NOT
|
|
337
|
+
* happened** — i.e. the rule should fire.
|
|
338
|
+
*
|
|
339
|
+
* Single pipeline: the merge via timestamp ordering collapses the
|
|
340
|
+
* prior two-path structure (specialized tool_call-scope speculative-
|
|
341
|
+
* allow running only on stale/absent real entries) into one uniform
|
|
342
|
+
* sort-and-compare. Synthetic entries carry reserved timestamps above
|
|
343
|
+
* all real entries in the same type (see
|
|
344
|
+
* {@link synthesizeSpeculativeEntries}'s timestamp convention); so on
|
|
345
|
+
* an `&&` chain where the prior ref would produce `event`, the
|
|
346
|
+
* merged timeline correctly treats the event as fresher than any
|
|
347
|
+
* stale real entry of the same type.
|
|
348
|
+
*
|
|
349
|
+
* ADR §5 scope semantics are applied to real entries only —
|
|
350
|
+
* speculative entries are always considered in-scope. Synthetic
|
|
351
|
+
* entries represent "about to happen in the current tool_call", and
|
|
352
|
+
* the current tool_call is always part of the current agent_loop and
|
|
353
|
+
* session, so a scope subset check adds no signal. This also means a
|
|
354
|
+
* rule using `in: "agent_loop"` and a rule using `in: "session"` see
|
|
355
|
+
* the same speculative view (correct — "about to happen" is scope-
|
|
356
|
+
* independent; a speculative entry newer than ALL real entries for
|
|
357
|
+
* the type is newer than any scope subset too).
|
|
358
|
+
*
|
|
359
|
+
* Inversion is handled by the caller via `when.not`. Authors wanting
|
|
360
|
+
* "fires when the event HAS happened" wrap this clause in `not:`.
|
|
361
|
+
*/
|
|
362
|
+
function evaluateHappened(value, ctx, ruleName) {
|
|
363
|
+
if (value === null ||
|
|
364
|
+
typeof value !== "object" ||
|
|
365
|
+
!("event" in value) ||
|
|
366
|
+
!("in" in value)) {
|
|
367
|
+
throw new Error(`[pi-steering] Rule "${ruleName}": when.happened ` +
|
|
368
|
+
`expected { event: string; in: "agent_loop" | "session" | "tool_call"; since?: string; notIn?: "agent_loop" | "session" | "tool_call" }; ` +
|
|
369
|
+
`got ${JSON.stringify(value)}`);
|
|
370
|
+
}
|
|
371
|
+
const { event, in: scope, since, notIn, } = value;
|
|
372
|
+
// Validate the scope string. The type system says
|
|
373
|
+
// `"agent_loop" | "session" | "tool_call"`, but a typo like
|
|
374
|
+
// `"agentLoop"` slips through TypeScript when the value arrives
|
|
375
|
+
// from a JSON source (import-json CLI, hand-written config, etc.).
|
|
376
|
+
// Surface those as loud runtime errors rather than silent
|
|
377
|
+
// fallthrough.
|
|
378
|
+
if (scope !== "agent_loop" && scope !== "session" && scope !== "tool_call") {
|
|
379
|
+
throw new Error(`[pi-steering] Rule "${ruleName}": ` +
|
|
380
|
+
`when.happened.in must be "agent_loop", "session", or "tool_call"; ` +
|
|
381
|
+
`got ${JSON.stringify(scope)}`);
|
|
382
|
+
}
|
|
383
|
+
if (since !== undefined && typeof since !== "string") {
|
|
384
|
+
throw new Error(`[pi-steering] Rule "${ruleName}": ` +
|
|
385
|
+
`when.happened.since must be a string if present; ` +
|
|
386
|
+
`got ${JSON.stringify(since)}`);
|
|
387
|
+
}
|
|
388
|
+
// Optional `notIn`: scope-subtraction modifier. Flat string — no
|
|
389
|
+
// nested object shape. Validated here rather than at load time to
|
|
390
|
+
// match the existing unknown-scope validation pattern (engine has no
|
|
391
|
+
// schema-level validation pass).
|
|
392
|
+
let innerScope = null;
|
|
393
|
+
if (notIn !== undefined) {
|
|
394
|
+
if (notIn !== "agent_loop" &&
|
|
395
|
+
notIn !== "session" &&
|
|
396
|
+
notIn !== "tool_call") {
|
|
397
|
+
throw new Error(`[pi-steering] Rule "${ruleName}": ` +
|
|
398
|
+
`when.happened.notIn must be "agent_loop", "session", or "tool_call"; ` +
|
|
399
|
+
`got ${JSON.stringify(notIn)}`);
|
|
400
|
+
}
|
|
401
|
+
if (notIn === scope) {
|
|
402
|
+
throw new Error(`[pi-steering] Rule "${ruleName}": ` +
|
|
403
|
+
`when.happened.in and when.happened.notIn are identical (${JSON.stringify(scope)}); subtraction is empty. Remove the "notIn" modifier.`);
|
|
404
|
+
}
|
|
405
|
+
if (SCOPE_ORDER[notIn] > SCOPE_ORDER[scope]) {
|
|
406
|
+
throw new Error(`[pi-steering] Rule "${ruleName}": ` +
|
|
407
|
+
`when.happened.notIn (${JSON.stringify(notIn)}) is a superset of when.happened.in (${JSON.stringify(scope)}); subtraction is empty. Adjust the scopes.`);
|
|
408
|
+
}
|
|
409
|
+
innerScope = notIn;
|
|
410
|
+
}
|
|
411
|
+
const sinceValue = typeof since === "string" ? since : undefined;
|
|
412
|
+
const eventLatest = latestTimestampSubtracted(event, scope, innerScope, ctx);
|
|
413
|
+
if (eventLatest === null) {
|
|
414
|
+
// Event absent in the (subtracted) timeline → rule fires.
|
|
415
|
+
return true;
|
|
416
|
+
}
|
|
417
|
+
if (sinceValue === undefined) {
|
|
418
|
+
// Simple presence check: event happened → rule does NOT fire.
|
|
419
|
+
return false;
|
|
420
|
+
}
|
|
421
|
+
const sinceLatest = latestTimestampSubtracted(sinceValue, scope, innerScope, ctx);
|
|
422
|
+
if (sinceLatest === null) {
|
|
423
|
+
// Invalidator never written in the (subtracted) timeline →
|
|
424
|
+
// degrade to simple-happened semantics (event wins).
|
|
425
|
+
return false;
|
|
426
|
+
}
|
|
427
|
+
// Both present. Event counts as happened iff its latest entry
|
|
428
|
+
// is strictly newer than the invalidator's.
|
|
429
|
+
return eventLatest <= sinceLatest;
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Latest timestamp across the unified real + speculative timeline
|
|
433
|
+
* for the given customType, with optional set-subtraction against an
|
|
434
|
+
* inner scope. `null` when no entries remain after subtraction.
|
|
435
|
+
*
|
|
436
|
+
* Semantics:
|
|
437
|
+
* - Outer scope `"tool_call"`: real entries are skipped entirely
|
|
438
|
+
* (real entries are never "within this one bash invocation");
|
|
439
|
+
* only speculative entries count. Exactly the existing "about to
|
|
440
|
+
* happen in THIS command" semantic.
|
|
441
|
+
* - Outer `"agent_loop"`: real entries scope-filtered by
|
|
442
|
+
* `_agentLoopIndex`; speculative always included.
|
|
443
|
+
* - Outer `"session"`: all real entries; speculative always included.
|
|
444
|
+
*
|
|
445
|
+
* When `innerScope` is non-null, the subtraction removes entries that
|
|
446
|
+
* are in `innerScope` from the entry stream BEFORE the timestamp max.
|
|
447
|
+
* Since speculative entries are `tool_call`-scope by construction and
|
|
448
|
+
* `tool_call ⊂ agent_loop ⊂ session`, ANY non-null `innerScope`
|
|
449
|
+
* subtracts all speculative entries. For real entries, the inner
|
|
450
|
+
* scope's membership predicate gates which are excluded.
|
|
451
|
+
*
|
|
452
|
+
* Invariant (enforced by {@link evaluateHappened}'s validation):
|
|
453
|
+
* `innerScope === null` OR `SCOPE_ORDER[innerScope] <= SCOPE_ORDER[outer]`
|
|
454
|
+
* AND `innerScope !== outer`. Callers passing anything else get a
|
|
455
|
+
* configuration error before arriving here.
|
|
456
|
+
*/
|
|
457
|
+
function latestTimestampSubtracted(customType, outer, innerScope, ctx) {
|
|
458
|
+
let latest = -Infinity;
|
|
459
|
+
// Real entries: in outer scope AND NOT in inner scope.
|
|
460
|
+
// Outer = "tool_call" excludes all real entries outright.
|
|
461
|
+
if (outer !== "tool_call") {
|
|
462
|
+
const inOuter = realEntryInScope(outer, ctx);
|
|
463
|
+
const inInner = innerScope !== null && innerScope !== "tool_call"
|
|
464
|
+
? realEntryInScope(innerScope, ctx)
|
|
465
|
+
: null;
|
|
466
|
+
for (const entry of ctx.findEntries(customType)) {
|
|
467
|
+
if (!inOuter(entry))
|
|
468
|
+
continue;
|
|
469
|
+
if (inInner !== null && inInner(entry))
|
|
470
|
+
continue;
|
|
471
|
+
if (entry.timestamp > latest)
|
|
472
|
+
latest = entry.timestamp;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
// Speculative entries are always `tool_call` scope. Any non-null
|
|
476
|
+
// inner scope subtracts them (tool_call itself, or a superset that
|
|
477
|
+
// includes tool_call). When inner is null, keep them.
|
|
478
|
+
if (innerScope === null) {
|
|
479
|
+
const speculative = speculativeEntriesFor(ctx, customType);
|
|
480
|
+
for (const entry of speculative) {
|
|
481
|
+
if (entry.timestamp > latest)
|
|
482
|
+
latest = entry.timestamp;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return latest === -Infinity ? null : latest;
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Read the speculative-entry slice for `customType` off
|
|
489
|
+
* `ctx.walkerState.events`. Returns an empty array when walkerState
|
|
490
|
+
* is undefined (non-bash candidates) or carries no `events` field
|
|
491
|
+
* (configs with no observers producing synthesis entries for this
|
|
492
|
+
* event → the synthesis pass returned empty views per ref).
|
|
493
|
+
*/
|
|
494
|
+
function speculativeEntriesFor(ctx, customType) {
|
|
495
|
+
const events = ctx.walkerState?.["events"];
|
|
496
|
+
return events?.[customType] ?? [];
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Scope nesting order used for superset detection in happened.notIn
|
|
500
|
+
* validation. `tool_call ⊂ agent_loop ⊂ session`; a higher number
|
|
501
|
+
* means a broader scope.
|
|
502
|
+
*/
|
|
503
|
+
const SCOPE_ORDER = {
|
|
504
|
+
tool_call: 0,
|
|
505
|
+
agent_loop: 1,
|
|
506
|
+
session: 2,
|
|
507
|
+
};
|
|
508
|
+
/**
|
|
509
|
+
* Build a per-entry filter for a scope as it applies to REAL entries
|
|
510
|
+
* (session JSONL). Speculative entries are filtered elsewhere since
|
|
511
|
+
* they have their own scope semantics.
|
|
512
|
+
*
|
|
513
|
+
* For a scope `tool_call`, real entries never match (no real entry
|
|
514
|
+
* originates from the current tool_call's speculative view).
|
|
515
|
+
*/
|
|
516
|
+
function realEntryInScope(scope, ctx) {
|
|
517
|
+
if (scope === "session") {
|
|
518
|
+
return () => true;
|
|
519
|
+
}
|
|
520
|
+
if (scope === "tool_call") {
|
|
521
|
+
return () => false;
|
|
522
|
+
}
|
|
523
|
+
const target = ctx.agentLoopIndex;
|
|
524
|
+
return (entry) => {
|
|
525
|
+
const tag = entry.data?.[AGENT_LOOP_INDEX_KEY];
|
|
526
|
+
return tag === target;
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
// ---------------------------------------------------------------------------
|
|
530
|
+
// Trinary leaf adapter + Kleene composition
|
|
531
|
+
// ---------------------------------------------------------------------------
|
|
532
|
+
/**
|
|
533
|
+
* Read the leaf-level `onUnknown:` modifier from a leaf value. Bare
|
|
534
|
+
* forms (string, RegExp, array, boolean, number, etc.) carry no
|
|
535
|
+
* modifiers; only the spread object form's `onUnknown:` field is
|
|
536
|
+
* consulted. Falls back to `"block"` (fail-CLOSED) when absent.
|
|
537
|
+
*
|
|
538
|
+
* Strict equality on `"allow"` mirrors the engine's typo-defense: any
|
|
539
|
+
* other value (`"Allow"` capitalization typo, `"BLOCK"`, `undefined`,
|
|
540
|
+
* numeric, etc.) collapses to `"block"`.
|
|
541
|
+
*/
|
|
542
|
+
function readLeafOnUnknown(value) {
|
|
543
|
+
if (value !== null &&
|
|
544
|
+
typeof value === "object" &&
|
|
545
|
+
!Array.isArray(value) &&
|
|
546
|
+
!(value instanceof RegExp) &&
|
|
547
|
+
"onUnknown" in value) {
|
|
548
|
+
const v = value.onUnknown;
|
|
549
|
+
return v === "allow" ? "allow" : "block";
|
|
550
|
+
}
|
|
551
|
+
return "block";
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Project a {@link PredicateVerdict} to a definite boolean using the
|
|
555
|
+
* supplied `onUnknown:` policy:
|
|
556
|
+
* - `"block"` (default, fail-CLOSED): `"unknown"` → `true` (the
|
|
557
|
+
* leaf reports "match" so the rule fires).
|
|
558
|
+
* - `"allow"` (fail-OPEN): `"unknown"` → `false` (the leaf reports
|
|
559
|
+
* "no match" so the rule skips).
|
|
560
|
+
*
|
|
561
|
+
* `true` / `false` pass through unchanged.
|
|
562
|
+
*/
|
|
563
|
+
function projectVerdict(verdict, onUnknown) {
|
|
564
|
+
if (verdict === "unknown")
|
|
565
|
+
return onUnknown === "block";
|
|
566
|
+
return verdict;
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Invoke a plugin-registered handler against the leaf value, awaiting
|
|
570
|
+
* any returned promise and narrowing the result to a trinary
|
|
571
|
+
* {@link PredicateVerdict}. A handler that throws synchronously OR
|
|
572
|
+
* returns a rejected promise is caught and treated as `"unknown"`,
|
|
573
|
+
* matching the spec's contract: "Throwing is equivalent to returning
|
|
574
|
+
* `"unknown"`; prefer explicit returns."
|
|
575
|
+
*
|
|
576
|
+
* The throw-as-unknown semantics preserve fail-CLOSED-by-default — a
|
|
577
|
+
* buggy plugin handler whose `"unknown"` then routes through the
|
|
578
|
+
* default `onUnknown: "block"` policy keeps the rule firing instead
|
|
579
|
+
* of silently fail-OPEN-skipping. Handler errors are logged via
|
|
580
|
+
* `console.warn` so plugin authors can debug; the rule name +
|
|
581
|
+
* `@<source>` tag + key are included so the source of the throw is
|
|
582
|
+
* unambiguous and operators can grep the warning channel by source
|
|
583
|
+
* tag (matching the S1 wrapper format `predicate threw for rule
|
|
584
|
+
* "<name>"@<source>` in {@link runPredicateChain}). See
|
|
585
|
+
* ../INVARIANTS.md for the S/E tag glossary.
|
|
586
|
+
*/
|
|
587
|
+
async function evaluateLeafTrinary(handler, value, ctx, ruleName, source, key) {
|
|
588
|
+
try {
|
|
589
|
+
const result = await handler(value, ctx);
|
|
590
|
+
if (result === true)
|
|
591
|
+
return true;
|
|
592
|
+
if (result === false)
|
|
593
|
+
return false;
|
|
594
|
+
if (result === "unknown")
|
|
595
|
+
return "unknown";
|
|
596
|
+
// Defensive: a handler returning anything else is buggy. Treat as
|
|
597
|
+
// unknown for fail-CLOSED-by-default; log so the plugin author can
|
|
598
|
+
// trace it.
|
|
599
|
+
console.warn(`[pi-steering] Rule "${ruleName}"@${source}: when.${key} handler returned ` +
|
|
600
|
+
`${JSON.stringify(result)}; expected boolean | "unknown". ` +
|
|
601
|
+
`Treating as "unknown"; the configured onUnknown policy will ` +
|
|
602
|
+
`project this to a definite verdict.`);
|
|
603
|
+
return "unknown";
|
|
604
|
+
}
|
|
605
|
+
catch (err) {
|
|
606
|
+
const msg = err instanceof Error ? `${err.message}\n${err.stack ?? ""}` : String(err);
|
|
607
|
+
console.warn(`[pi-steering] Rule "${ruleName}"@${source}: when.${key} handler threw: ${msg}`);
|
|
608
|
+
return "unknown";
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* Kleene 3-valued AND across an array of trinary verdicts. Used to
|
|
613
|
+
* compose multi-leaf `not:` blocks before the not-flip applies.
|
|
614
|
+
*
|
|
615
|
+
* Truth table (`x AND y`):
|
|
616
|
+
* true & true = true
|
|
617
|
+
* false & anything = false (false absorbs)
|
|
618
|
+
* anything & false = false
|
|
619
|
+
* true & unknown = unknown
|
|
620
|
+
* unknown & true = unknown
|
|
621
|
+
* unknown & unknown = unknown
|
|
622
|
+
*
|
|
623
|
+
* Empty input is `true` (vacuous truth) — but not-block evaluation
|
|
624
|
+
* rejects the empty case at config-resolve time via
|
|
625
|
+
* {@link validateWhenClauseShape}, so this code path only runs on
|
|
626
|
+
* non-empty leaf sets.
|
|
627
|
+
*/
|
|
628
|
+
function kleeneAnd(verdicts) {
|
|
629
|
+
let anyUnknown = false;
|
|
630
|
+
for (const v of verdicts) {
|
|
631
|
+
if (v === false)
|
|
632
|
+
return false; // false absorbs
|
|
633
|
+
if (v === "unknown")
|
|
634
|
+
anyUnknown = true;
|
|
635
|
+
}
|
|
636
|
+
return anyUnknown ? "unknown" : true;
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* Evaluate a `not:` block per the corrected pseudocode.
|
|
640
|
+
*
|
|
641
|
+
* Inside `not:`, leaves are composed with Kleene 3-valued AND; the
|
|
642
|
+
* block-level `onUnknown:` modifier (default `"block"`) projects the
|
|
643
|
+
* unknown-leaf case to a definite verdict BEFORE the not-flip applies.
|
|
644
|
+
* This is a deliberate deviation from pure Kleene: the flip is skipped
|
|
645
|
+
* when leaves resolve via `onUnknown:` policy so that
|
|
646
|
+
* `not: { cwd: P, onUnknown: "block" }` directly means "rule fires"
|
|
647
|
+
* without requiring the user to invert.
|
|
648
|
+
*
|
|
649
|
+
* Returns a definite boolean (the not-clause's contribution to the
|
|
650
|
+
* outer when-clause's AND): `true` means the not-clause matched (rule
|
|
651
|
+
* fires); `false` means it didn't.
|
|
652
|
+
*
|
|
653
|
+
* Truth-table coverage:
|
|
654
|
+
* - all-true leaves → not(true) = false (rule skips on this
|
|
655
|
+
* not-clause).
|
|
656
|
+
* - any-false leaf → false absorbs in Kleene AND → not(false) =
|
|
657
|
+
* true (rule fires).
|
|
658
|
+
* - some-unknown, no-false → Kleene AND = "unknown" → block-level
|
|
659
|
+
* `onUnknown:` policy projects directly without the flip:
|
|
660
|
+
* "block" → not-clause = true (fail-CLOSED — rule fires)
|
|
661
|
+
* "allow" → not-clause = false (fail-OPEN — rule skips)
|
|
662
|
+
*/
|
|
663
|
+
async function evaluateNotBlock(block, state, ctx, predicates, ruleName, source) {
|
|
664
|
+
// Read block-level `onUnknown:` modifier. Default fail-CLOSED.
|
|
665
|
+
const blockOnUnknown = block.onUnknown === "allow"
|
|
666
|
+
? "allow"
|
|
667
|
+
: "block";
|
|
668
|
+
// Evaluate each leaf to a trinary verdict. Reserved keys (modifiers
|
|
669
|
+
// + the operator field) are skipped; nested `not:` recursion is
|
|
670
|
+
// rejected at runtime in {@link validateWhenClauseShape} — the
|
|
671
|
+
// type-level ban via {@link TopLevelWhenClauseNoRecurse} catches
|
|
672
|
+
// authoring-time mistakes, the validator catches JSON / `as any`
|
|
673
|
+
// escape hatches before the engine ever runs. The unknown-predicate
|
|
674
|
+
// check still fires for unregistered keys.
|
|
675
|
+
const verdicts = [];
|
|
676
|
+
for (const [key, value] of Object.entries(block)) {
|
|
677
|
+
if (value === undefined)
|
|
678
|
+
continue;
|
|
679
|
+
if (isReservedPredicateKey(key))
|
|
680
|
+
continue;
|
|
681
|
+
// Built-in: cwd — trinary on walker-unknown sentinel.
|
|
682
|
+
if (key === "cwd") {
|
|
683
|
+
verdicts.push(evaluateCwd(value, state.cwd));
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
// Built-in: happened — boolean leaf, no walker-unknown semantics
|
|
687
|
+
// (it consults session entries / speculative entries).
|
|
688
|
+
if (key === "happened") {
|
|
689
|
+
verdicts.push(evaluateHappened(value, ctx, ruleName));
|
|
690
|
+
continue;
|
|
691
|
+
}
|
|
692
|
+
// Built-in: condition — escape-hatch boolean callback. Treat
|
|
693
|
+
// throws as `"unknown"` for parity with plugin handlers.
|
|
694
|
+
if (key === "condition") {
|
|
695
|
+
const fn = value;
|
|
696
|
+
try {
|
|
697
|
+
const result = await fn(ctx);
|
|
698
|
+
verdicts.push(Boolean(result));
|
|
699
|
+
}
|
|
700
|
+
catch (err) {
|
|
701
|
+
const msg = err instanceof Error
|
|
702
|
+
? `${err.message}\n${err.stack ?? ""}`
|
|
703
|
+
: String(err);
|
|
704
|
+
console.warn(`[pi-steering] Rule "${ruleName}"@${source}: when.condition (inside not:) ` +
|
|
705
|
+
`threw: ${msg}`);
|
|
706
|
+
verdicts.push("unknown");
|
|
707
|
+
}
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
// Plugin-registered predicate. Unknown predicate → named error.
|
|
711
|
+
const handler = predicates[key];
|
|
712
|
+
if (handler === undefined)
|
|
713
|
+
throw new UnknownPredicateError(key);
|
|
714
|
+
verdicts.push(await evaluateLeafTrinary(handler, value, ctx, ruleName, source, key));
|
|
715
|
+
}
|
|
716
|
+
const combined = kleeneAnd(verdicts);
|
|
717
|
+
if (combined === false) {
|
|
718
|
+
// false absorbs → not(false) = true (rule fires on this not-clause).
|
|
719
|
+
return true;
|
|
720
|
+
}
|
|
721
|
+
if (combined === "unknown") {
|
|
722
|
+
// Skip the not-flip: the block-level `onUnknown:` policy directly
|
|
723
|
+
// produces the rule-level outcome. "block" → fire; "allow" → skip.
|
|
724
|
+
return blockOnUnknown === "block";
|
|
725
|
+
}
|
|
726
|
+
// All-true → not(true) = false (rule skips on this not-clause).
|
|
727
|
+
return false;
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Evaluate a {@link TopLevelWhenClause}: returns true if every predicate in the
|
|
731
|
+
* clause "matches" for the given context. An empty / undefined clause
|
|
732
|
+
* trivially matches (rule fires regardless of `when`).
|
|
733
|
+
*
|
|
734
|
+
* Dispatch table:
|
|
735
|
+
* - `cwd` — built-in (walker-tied), consumes `state.cwd`. Returns
|
|
736
|
+
* trinary; outer leaf-level `onUnknown:` modifier on
|
|
737
|
+
* the spread form projects to a definite boolean via
|
|
738
|
+
* {@link projectVerdict} (default `"block"` =
|
|
739
|
+
* fail-CLOSED).
|
|
740
|
+
* - `happened` — built-in (session-entry-scoped), consumes
|
|
741
|
+
* `ctx.findEntries` + `ctx.agentLoopIndex`. Boolean.
|
|
742
|
+
* - `not` — nested `not:` block; dispatched to
|
|
743
|
+
* {@link evaluateNotBlock} which composes leaves with
|
|
744
|
+
* Kleene 3-valued AND and applies the block-level
|
|
745
|
+
* `onUnknown:` policy without the not-flip on unknown
|
|
746
|
+
* leaves.
|
|
747
|
+
* - `condition` — {@link PredicateFn}; call with ctx. Throws caught
|
|
748
|
+
* and treated as `"unknown"`. Outer-level
|
|
749
|
+
* `condition:` is bare-`PredicateFn`-typed (no
|
|
750
|
+
* spread shape), so the projection always uses the
|
|
751
|
+
* default `"block"` policy and a throwing condition
|
|
752
|
+
* fires the rule fail-CLOSED. Mirrors the inner
|
|
753
|
+
* not-block exception treatment + the
|
|
754
|
+
* plugin-handler contract in
|
|
755
|
+
* {@link evaluateLeafTrinary}.
|
|
756
|
+
* - anything else — `predicates[key]`; trinary handler with
|
|
757
|
+
* leaf-level `onUnknown:` modifier projection. Throws
|
|
758
|
+
* treated as `"unknown"` per spec.
|
|
759
|
+
*
|
|
760
|
+
* Reserved keys (`onUnknown`, future modifiers) are skipped here too —
|
|
761
|
+
* they're meaningful as siblings to leaves at the outer level (per
|
|
762
|
+
* spread form `{ pattern, onUnknown }` placement) but the engine
|
|
763
|
+
* doesn't iterate them as standalone keys; the leaf adapter consumes
|
|
764
|
+
* them inline. A bare `onUnknown:` at the outer level (without a
|
|
765
|
+
* containing leaf) is type-banned but skipped here defensively.
|
|
766
|
+
*/
|
|
767
|
+
export async function evaluateWhen(when, state, ctx, predicates, ruleName, source) {
|
|
768
|
+
if (!when)
|
|
769
|
+
return true;
|
|
770
|
+
for (const [key, value] of Object.entries(when)) {
|
|
771
|
+
if (value === undefined)
|
|
772
|
+
continue;
|
|
773
|
+
// Skip modifier-key siblings at the outer level (defensive — the
|
|
774
|
+
// type system bans rule-level `onUnknown:`, but a JSON config
|
|
775
|
+
// could slip one through). The `not:` operator field is NOT
|
|
776
|
+
// skipped here — it's a leaf that produces a verdict via Kleene
|
|
777
|
+
// composition of the inner not-block, dispatched below.
|
|
778
|
+
if (isModifierKey(key))
|
|
779
|
+
continue;
|
|
780
|
+
// Built-in: cwd. Trinary leaf with leaf-level `onUnknown:` policy.
|
|
781
|
+
if (key === "cwd") {
|
|
782
|
+
const verdict = evaluateCwd(value, state.cwd);
|
|
783
|
+
const onUnknown = readLeafOnUnknown(value);
|
|
784
|
+
if (!projectVerdict(verdict, onUnknown))
|
|
785
|
+
return false;
|
|
786
|
+
continue;
|
|
787
|
+
}
|
|
788
|
+
// Built-in: happened (session-entry presence check). Boolean.
|
|
789
|
+
if (key === "happened") {
|
|
790
|
+
if (!evaluateHappened(value, ctx, ruleName))
|
|
791
|
+
return false;
|
|
792
|
+
continue;
|
|
793
|
+
}
|
|
794
|
+
// Built-in: not (recursive inversion via the corrected
|
|
795
|
+
// not-block evaluator).
|
|
796
|
+
if (key === "not") {
|
|
797
|
+
const nested = value;
|
|
798
|
+
const notFires = await evaluateNotBlock(nested, state, ctx, predicates, ruleName, source);
|
|
799
|
+
if (!notFires)
|
|
800
|
+
return false;
|
|
801
|
+
continue;
|
|
802
|
+
}
|
|
803
|
+
// Built-in: condition (escape-hatch function). The callback
|
|
804
|
+
// returns boolean; throws (sync or rejected promise) are caught
|
|
805
|
+
// and projected via the "unknown" policy. Outer-level
|
|
806
|
+
// `condition:` is bare-`PredicateFn`-typed (no spread shape), so
|
|
807
|
+
// no leaf-level `onUnknown:` opt-in exists at this site — the
|
|
808
|
+
// projection always uses the default `"block"` policy and a
|
|
809
|
+
// throwing condition fires the rule fail-CLOSED. This mirrors the
|
|
810
|
+
// inner not-block branch's exception treatment so a `condition:`
|
|
811
|
+
// callback exhibits identical behavior at outer-vs-inner
|
|
812
|
+
// placement, and matches the plugin-handler exception contract
|
|
813
|
+
// in {@link evaluateLeafTrinary}.
|
|
814
|
+
if (key === "condition") {
|
|
815
|
+
const fn = value;
|
|
816
|
+
let verdict;
|
|
817
|
+
try {
|
|
818
|
+
verdict = Boolean(await fn(ctx));
|
|
819
|
+
}
|
|
820
|
+
catch (err) {
|
|
821
|
+
const msg = err instanceof Error
|
|
822
|
+
? `${err.message}\n${err.stack ?? ""}`
|
|
823
|
+
: String(err);
|
|
824
|
+
console.warn(`[pi-steering] Rule "${ruleName}"@${source}: when.condition threw: ${msg}`);
|
|
825
|
+
verdict = "unknown";
|
|
826
|
+
}
|
|
827
|
+
// `condition?:` is bare PredicateFn (no spread shape); leaf-level
|
|
828
|
+
// `onUnknown:` is not reachable from the schema. Hard-code default
|
|
829
|
+
// `"block"` policy for symmetry with the plugin-handler exception
|
|
830
|
+
// contract. Authors needing fail-OPEN wrap inside
|
|
831
|
+
// `not: { condition: fn, onUnknown: "allow" }` (block-level
|
|
832
|
+
// modifier) OR catch the throw inside the callback body.
|
|
833
|
+
if (!projectVerdict(verdict, "block"))
|
|
834
|
+
return false;
|
|
835
|
+
continue;
|
|
836
|
+
}
|
|
837
|
+
// Plugin-registered predicate. Trinary leaf adapter awaits, narrows,
|
|
838
|
+
// catches throws, then leaf-level `onUnknown:` projects to boolean.
|
|
839
|
+
const handler = predicates[key];
|
|
840
|
+
if (handler === undefined)
|
|
841
|
+
throw new UnknownPredicateError(key);
|
|
842
|
+
const verdict = await evaluateLeafTrinary(handler, value, ctx, ruleName, source, key);
|
|
843
|
+
const onUnknown = readLeafOnUnknown(value);
|
|
844
|
+
if (!projectVerdict(verdict, onUnknown))
|
|
845
|
+
return false;
|
|
846
|
+
}
|
|
847
|
+
return true;
|
|
848
|
+
}
|
|
849
|
+
//# sourceMappingURL=predicates.js.map
|