@cad0p/pi-steering 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/LICENSE +29 -0
- package/README.md +920 -0
- package/dist/__test-helpers__.d.ts +95 -0
- package/dist/__test-helpers__.d.ts.map +1 -0
- package/dist/__test-helpers__.js +174 -0
- package/dist/__test-helpers__.js.map +1 -0
- package/dist/bin/pi-steering.d.ts +9 -0
- package/dist/bin/pi-steering.d.ts.map +1 -0
- package/dist/bin/pi-steering.js +506 -0
- package/dist/bin/pi-steering.js.map +1 -0
- package/dist/compat.d.ts +50 -0
- package/dist/compat.d.ts.map +1 -0
- package/dist/compat.js +180 -0
- package/dist/compat.js.map +1 -0
- package/dist/defaults.d.ts +61 -0
- package/dist/defaults.d.ts.map +1 -0
- package/dist/defaults.js +106 -0
- package/dist/defaults.js.map +1 -0
- package/dist/define-config.d.ts +235 -0
- package/dist/define-config.d.ts.map +1 -0
- package/dist/define-config.js +118 -0
- package/dist/define-config.js.map +1 -0
- package/dist/define-predicate.d.ts +49 -0
- package/dist/define-predicate.d.ts.map +1 -0
- package/dist/define-predicate.js +35 -0
- package/dist/define-predicate.js.map +1 -0
- package/dist/evaluator-internals/context.d.ts +186 -0
- package/dist/evaluator-internals/context.d.ts.map +1 -0
- package/dist/evaluator-internals/context.js +219 -0
- package/dist/evaluator-internals/context.js.map +1 -0
- package/dist/evaluator-internals/override.d.ts +19 -0
- package/dist/evaluator-internals/override.d.ts.map +1 -0
- package/dist/evaluator-internals/override.js +79 -0
- package/dist/evaluator-internals/override.js.map +1 -0
- package/dist/evaluator-internals/predicates.d.ts +152 -0
- package/dist/evaluator-internals/predicates.d.ts.map +1 -0
- package/dist/evaluator-internals/predicates.js +849 -0
- package/dist/evaluator-internals/predicates.js.map +1 -0
- package/dist/evaluator-internals/speculative-synthesis.d.ts +102 -0
- package/dist/evaluator-internals/speculative-synthesis.d.ts.map +1 -0
- package/dist/evaluator-internals/speculative-synthesis.js +101 -0
- package/dist/evaluator-internals/speculative-synthesis.js.map +1 -0
- package/dist/evaluator.d.ts +101 -0
- package/dist/evaluator.d.ts.map +1 -0
- package/dist/evaluator.js +714 -0
- package/dist/evaluator.js.map +1 -0
- package/dist/helpers/walker-unknown-cwd-reason.d.ts +68 -0
- package/dist/helpers/walker-unknown-cwd-reason.d.ts.map +1 -0
- package/dist/helpers/walker-unknown-cwd-reason.js +73 -0
- package/dist/helpers/walker-unknown-cwd-reason.js.map +1 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +93 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/drop-unused-observers.d.ts +43 -0
- package/dist/internal/drop-unused-observers.d.ts.map +1 -0
- package/dist/internal/drop-unused-observers.js +50 -0
- package/dist/internal/drop-unused-observers.js.map +1 -0
- package/dist/internal/finalize-plugin-state.d.ts +22 -0
- package/dist/internal/finalize-plugin-state.d.ts.map +1 -0
- package/dist/internal/finalize-plugin-state.js +14 -0
- package/dist/internal/finalize-plugin-state.js.map +1 -0
- package/dist/internal/merge-observers.d.ts +30 -0
- package/dist/internal/merge-observers.d.ts.map +1 -0
- package/dist/internal/merge-observers.js +27 -0
- package/dist/internal/merge-observers.js.map +1 -0
- package/dist/internal/pattern-utils.d.ts +33 -0
- package/dist/internal/pattern-utils.d.ts.map +1 -0
- package/dist/internal/pattern-utils.js +20 -0
- package/dist/internal/pattern-utils.js.map +1 -0
- package/dist/internal/ref-text.d.ts +11 -0
- package/dist/internal/ref-text.d.ts.map +1 -0
- package/dist/internal/ref-text.js +15 -0
- package/dist/internal/ref-text.js.map +1 -0
- package/dist/internal/session-runtime.d.ts +44 -0
- package/dist/internal/session-runtime.d.ts.map +1 -0
- package/dist/internal/session-runtime.js +143 -0
- package/dist/internal/session-runtime.js.map +1 -0
- package/dist/internal/watch-matcher.d.ts +56 -0
- package/dist/internal/watch-matcher.d.ts.map +1 -0
- package/dist/internal/watch-matcher.js +179 -0
- package/dist/internal/watch-matcher.js.map +1 -0
- package/dist/loader.d.ts +97 -0
- package/dist/loader.d.ts.map +1 -0
- package/dist/loader.js +527 -0
- package/dist/loader.js.map +1 -0
- package/dist/observer-dispatcher.d.ts +69 -0
- package/dist/observer-dispatcher.d.ts.map +1 -0
- package/dist/observer-dispatcher.js +184 -0
- package/dist/observer-dispatcher.js.map +1 -0
- package/dist/plugin-merger.d.ts +175 -0
- package/dist/plugin-merger.d.ts.map +1 -0
- package/dist/plugin-merger.js +450 -0
- package/dist/plugin-merger.js.map +1 -0
- package/dist/plugins/git/branch-tracker.d.ts +85 -0
- package/dist/plugins/git/branch-tracker.d.ts.map +1 -0
- package/dist/plugins/git/branch-tracker.js +181 -0
- package/dist/plugins/git/branch-tracker.js.map +1 -0
- package/dist/plugins/git/cwd-extensions.d.ts +10 -0
- package/dist/plugins/git/cwd-extensions.d.ts.map +1 -0
- package/dist/plugins/git/cwd-extensions.js +155 -0
- package/dist/plugins/git/cwd-extensions.js.map +1 -0
- package/dist/plugins/git/git-ops.d.ts +89 -0
- package/dist/plugins/git/git-ops.d.ts.map +1 -0
- package/dist/plugins/git/git-ops.js +107 -0
- package/dist/plugins/git/git-ops.js.map +1 -0
- package/dist/plugins/git/index.d.ts +225 -0
- package/dist/plugins/git/index.d.ts.map +1 -0
- package/dist/plugins/git/index.js +52 -0
- package/dist/plugins/git/index.js.map +1 -0
- package/dist/plugins/git/predicates.d.ts +312 -0
- package/dist/plugins/git/predicates.d.ts.map +1 -0
- package/dist/plugins/git/predicates.js +580 -0
- package/dist/plugins/git/predicates.js.map +1 -0
- package/dist/plugins/git/rules.d.ts +209 -0
- package/dist/plugins/git/rules.d.ts.map +1 -0
- package/dist/plugins/git/rules.js +267 -0
- package/dist/plugins/git/rules.js.map +1 -0
- package/dist/schema.d.ts +1799 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +4 -0
- package/dist/schema.js.map +1 -0
- package/dist/testing/index.d.ts +621 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +747 -0
- package/dist/testing/index.js.map +1 -0
- package/examples/README.md +83 -0
- package/examples/combined-git-discipline/README.md +52 -0
- package/examples/combined-git-discipline/node_modules/.bin/pi-steering +43 -0
- package/examples/combined-git-discipline/package.json +18 -0
- package/examples/combined-git-discipline/steering.json +27 -0
- package/examples/combined-git-discipline/steering.test.ts +42 -0
- package/examples/combined-git-discipline/steering.ts +64 -0
- package/examples/combined-git-discipline/tsconfig.json +7 -0
- package/examples/draft-prs-only/README.md +42 -0
- package/examples/draft-prs-only/node_modules/.bin/pi-steering +43 -0
- package/examples/draft-prs-only/package.json +18 -0
- package/examples/draft-prs-only/steering.json +12 -0
- package/examples/draft-prs-only/steering.test.ts +48 -0
- package/examples/draft-prs-only/steering.ts +36 -0
- package/examples/draft-prs-only/tsconfig.json +7 -0
- package/examples/dynamic-reason-runtime-cwd/README.md +148 -0
- package/examples/dynamic-reason-runtime-cwd/node_modules/.bin/pi-steering +43 -0
- package/examples/dynamic-reason-runtime-cwd/package.json +18 -0
- package/examples/dynamic-reason-runtime-cwd/steering.test.ts +130 -0
- package/examples/dynamic-reason-runtime-cwd/steering.ts +82 -0
- package/examples/dynamic-reason-runtime-cwd/tsconfig.json +7 -0
- package/examples/force-push-strict/README.md +75 -0
- package/examples/force-push-strict/node_modules/.bin/pi-steering +43 -0
- package/examples/force-push-strict/package.json +18 -0
- package/examples/force-push-strict/steering.json +12 -0
- package/examples/force-push-strict/steering.test.ts +51 -0
- package/examples/force-push-strict/steering.ts +54 -0
- package/examples/force-push-strict/tsconfig.json +7 -0
- package/examples/no-amend/README.md +52 -0
- package/examples/no-amend/node_modules/.bin/pi-steering +43 -0
- package/examples/no-amend/package.json +18 -0
- package/examples/no-amend/steering.cwd-scoped.json +12 -0
- package/examples/no-amend/steering.json +11 -0
- package/examples/no-amend/steering.test.ts +49 -0
- package/examples/no-amend/steering.ts +38 -0
- package/examples/no-amend/tsconfig.json +7 -0
- package/examples/work-item-plugin/README.md +103 -0
- package/examples/work-item-plugin/node_modules/.bin/pi-steering +43 -0
- package/examples/work-item-plugin/package.json +19 -0
- package/examples/work-item-plugin/src/index.test.ts +168 -0
- package/examples/work-item-plugin/src/index.ts +122 -0
- package/examples/work-item-plugin/src/observers/npm-test-tracker.test.ts +95 -0
- package/examples/work-item-plugin/src/observers/npm-test-tracker.ts +88 -0
- package/examples/work-item-plugin/src/observers/retest-required-tracker.test.ts +81 -0
- package/examples/work-item-plugin/src/observers/retest-required-tracker.ts +81 -0
- package/examples/work-item-plugin/src/predicates/work-item-format.test.ts +157 -0
- package/examples/work-item-plugin/src/predicates/work-item-format.ts +99 -0
- package/examples/work-item-plugin/src/rules/commit-description-check.test.ts +107 -0
- package/examples/work-item-plugin/src/rules/commit-description-check.ts +83 -0
- package/examples/work-item-plugin/src/rules/commit-requires-work-item.test.ts +79 -0
- package/examples/work-item-plugin/src/rules/commit-requires-work-item.ts +56 -0
- package/examples/work-item-plugin/src/rules/push-requires-tests.test.ts +245 -0
- package/examples/work-item-plugin/src/rules/push-requires-tests.ts +62 -0
- package/examples/work-item-plugin/tsconfig.json +8 -0
- package/package.json +88 -0
- package/skills/steering-authoring/SKILL.md +102 -0
- package/src/INVARIANTS.md +116 -0
- package/src/__test-helpers__.ts +243 -0
- package/src/bin/pi-steering.test.ts +920 -0
- package/src/bin/pi-steering.ts +584 -0
- package/src/compat.test.ts +431 -0
- package/src/compat.ts +282 -0
- package/src/defaults.test.ts +585 -0
- package/src/defaults.ts +148 -0
- package/src/define-config.test.ts +888 -0
- package/src/define-config.ts +375 -0
- package/src/define-predicate.test.ts +70 -0
- package/src/define-predicate.ts +57 -0
- package/src/evaluator-internals/context.ts +290 -0
- package/src/evaluator-internals/override.test.ts +99 -0
- package/src/evaluator-internals/override.ts +83 -0
- package/src/evaluator-internals/predicates.ts +1037 -0
- package/src/evaluator-internals/speculative-synthesis.test.ts +411 -0
- package/src/evaluator-internals/speculative-synthesis.ts +199 -0
- package/src/evaluator.test.ts +6296 -0
- package/src/evaluator.ts +1106 -0
- package/src/factory-time-load.test.ts +630 -0
- package/src/helpers/walker-unknown-cwd-reason.test.ts +73 -0
- package/src/helpers/walker-unknown-cwd-reason.ts +83 -0
- package/src/index.test.ts +836 -0
- package/src/index.ts +229 -0
- package/src/internal/drop-unused-observers.test.ts +141 -0
- package/src/internal/drop-unused-observers.ts +81 -0
- package/src/internal/finalize-plugin-state.ts +43 -0
- package/src/internal/merge-observers.ts +51 -0
- package/src/internal/pattern-utils.test.ts +52 -0
- package/src/internal/pattern-utils.ts +38 -0
- package/src/internal/ref-text.test.ts +54 -0
- package/src/internal/ref-text.ts +17 -0
- package/src/internal/session-runtime.test.ts +629 -0
- package/src/internal/session-runtime.ts +201 -0
- package/src/internal/watch-matcher.test.ts +326 -0
- package/src/internal/watch-matcher.ts +200 -0
- package/src/jsdoc-propagation.test.ts +277 -0
- package/src/loader.test.ts +1098 -0
- package/src/loader.ts +581 -0
- package/src/not-block-onunknown.test.ts +554 -0
- package/src/observer-dispatcher.test.ts +939 -0
- package/src/observer-dispatcher.ts +290 -0
- package/src/per-predicate-typing.test.ts +361 -0
- package/src/plugin-merger.test.ts +790 -0
- package/src/plugin-merger.ts +598 -0
- package/src/plugins/git/README.md +485 -0
- package/src/plugins/git/branch-tracker.test.ts +281 -0
- package/src/plugins/git/branch-tracker.ts +187 -0
- package/src/plugins/git/cwd-extensions.test.ts +230 -0
- package/src/plugins/git/cwd-extensions.ts +160 -0
- package/src/plugins/git/git-ops.test.ts +397 -0
- package/src/plugins/git/git-ops.ts +173 -0
- package/src/plugins/git/index.ts +253 -0
- package/src/plugins/git/integration.test.ts +705 -0
- package/src/plugins/git/predicates.test.ts +1401 -0
- package/src/plugins/git/predicates.ts +693 -0
- package/src/plugins/git/rules.test.ts +1001 -0
- package/src/plugins/git/rules.ts +307 -0
- package/src/schema.test.ts +259 -0
- package/src/schema.ts +1963 -0
- package/src/testing/index.test.ts +1696 -0
- package/src/testing/index.ts +1472 -0
package/dist/schema.d.ts
ADDED
|
@@ -0,0 +1,1799 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 config schema - TS-first rules, plugins, observers, predicates.
|
|
3
|
+
*
|
|
4
|
+
* Additive to the v1 schema (see `../schema.ts`). The existing evaluator
|
|
5
|
+
* continues to drive the pi extension runtime on the v1 types; v2 types
|
|
6
|
+
* live in parallel and power the new `defineConfig` / TS loader path.
|
|
7
|
+
* Phase 3 rewrites the evaluator on top of this module and retires v1.
|
|
8
|
+
*
|
|
9
|
+
* Design references (see the accepted ADR, linked from PR #2's
|
|
10
|
+
* description):
|
|
11
|
+
* - "Design → Rule schema" → {@link Rule}, {@link TopLevelWhenClause},
|
|
12
|
+
* {@link Pattern}, {@link PredicateFn},
|
|
13
|
+
* {@link PredicateHandler}.
|
|
14
|
+
* - "Design → Observer schema" → {@link Observer},
|
|
15
|
+
* {@link ObserverContext}.
|
|
16
|
+
* - "Design → Plugin schema" → {@link Plugin}.
|
|
17
|
+
* - "Design → Predicate context" → {@link PredicateContext}.
|
|
18
|
+
* - "Design → Override default" → {@link SteeringConfig.defaultNoOverride}
|
|
19
|
+
* (default `true`, fail-closed).
|
|
20
|
+
*
|
|
21
|
+
* Nothing in this module executes rules, observers, or predicates. It
|
|
22
|
+
* only defines shapes. Evaluation is Phase 3's concern.
|
|
23
|
+
*/
|
|
24
|
+
import type { EnvState, Tracker, Word } from "@cad0p/unbash-walker";
|
|
25
|
+
/**
|
|
26
|
+
* Static or regex pattern accepted by all built-in string-valued
|
|
27
|
+
* predicates (`when.cwd`, `when.branch`, `when.upstream`, ...).
|
|
28
|
+
*
|
|
29
|
+
* A plain string is treated as a regex source (compiled once at load
|
|
30
|
+
* time by the evaluator - users escape literals themselves). A RegExp
|
|
31
|
+
* is used as-is.
|
|
32
|
+
*
|
|
33
|
+
* See ADR "Design → Rule schema" → Pattern.
|
|
34
|
+
*
|
|
35
|
+
* @see {@link Patterns} for the OR-of-matches shorthand used by
|
|
36
|
+
* pattern-leaf predicate registry augmentations.
|
|
37
|
+
*/
|
|
38
|
+
export type Pattern = string | RegExp;
|
|
39
|
+
/**
|
|
40
|
+
* The OR-of-patterns shorthand for pattern-leaf predicate
|
|
41
|
+
* registrations: a single {@link Pattern} or an array of
|
|
42
|
+
* {@link Pattern}s (interpreted as OR-of-matches: any pattern
|
|
43
|
+
* matching counts as a hit).
|
|
44
|
+
*
|
|
45
|
+
* Use as the bare-type parameter of {@link PredicateShape} when
|
|
46
|
+
* registering a pattern-leaf predicate that accepts both bare
|
|
47
|
+
* shorthand (`when: { myPredicate: /^foo$/ }`) and explicit object
|
|
48
|
+
* form (`when: { myPredicate: { pattern: /^foo$/ } }`):
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* ```ts
|
|
52
|
+
* import type { Patterns, PredicateShape } from "@cad0p/pi-steering";
|
|
53
|
+
*
|
|
54
|
+
* declare global {
|
|
55
|
+
* interface PiSteeringPredicates {
|
|
56
|
+
* myPredicate: PredicateShape<Patterns>;
|
|
57
|
+
* }
|
|
58
|
+
* }
|
|
59
|
+
* ```
|
|
60
|
+
*/
|
|
61
|
+
export type Patterns = Pattern | Pattern[];
|
|
62
|
+
/**
|
|
63
|
+
* Escape-hatch predicate: arbitrary user-supplied logic evaluated with a
|
|
64
|
+
* {@link PredicateContext}. Returned value gates whether the surrounding
|
|
65
|
+
* rule fires. Async OK - evaluator awaits it.
|
|
66
|
+
*
|
|
67
|
+
* Used as the value of `when.condition`, and as the fallback shape for
|
|
68
|
+
* plugin-registered custom keys on a {@link TopLevelWhenClause}.
|
|
69
|
+
*
|
|
70
|
+
* See ADR "Design → Rule schema" → PredicateFn.
|
|
71
|
+
*/
|
|
72
|
+
export type PredicateFn = (ctx: PredicateContext) => boolean | Promise<boolean>;
|
|
73
|
+
/**
|
|
74
|
+
* Dynamic block-reason function. When {@link Rule.reason} is a
|
|
75
|
+
* function, the evaluator invokes it with the same
|
|
76
|
+
* {@link PredicateContext} the predicates saw and prefixes the
|
|
77
|
+
* returned string with `[steering:<rule>@<source>] `. Async OK
|
|
78
|
+
* (evaluator awaits); thrown errors are logged to `console.warn` and
|
|
79
|
+
* replaced with a fail-safe fallback (`(reason failed to format;
|
|
80
|
+
* see log)`) so a broken reason doesn't leak its raw error message
|
|
81
|
+
* to the LLM.
|
|
82
|
+
*
|
|
83
|
+
* Use the function form when the block's human-readable context
|
|
84
|
+
* depends on runtime state — e.g. "Could not verify upstream at
|
|
85
|
+
* effective cwd \${ctx.walkerState.cwd}". Plain string reasons are
|
|
86
|
+
* preferred when the reason is static; they avoid the evaluator's
|
|
87
|
+
* extra await + try/catch.
|
|
88
|
+
*
|
|
89
|
+
* @example
|
|
90
|
+
* // Inject the current branch name into the reason when the walker
|
|
91
|
+
* // resolved it statically; fall back to a generic message when the
|
|
92
|
+
* // walker bails or the tracker's initial-sentinel flows through.
|
|
93
|
+
* reason: (ctx) => {
|
|
94
|
+
* const raw = ctx.walkerState?.branch;
|
|
95
|
+
* const branch =
|
|
96
|
+
* typeof raw === "string" && raw !== "" && raw !== "unknown"
|
|
97
|
+
* ? raw
|
|
98
|
+
* : undefined;
|
|
99
|
+
* const onClause = branch ? ` You are on '${branch}'.` : "";
|
|
100
|
+
* return `Don't commit directly to a protected branch.${onClause}`;
|
|
101
|
+
* };
|
|
102
|
+
*/
|
|
103
|
+
export type ReasonFn = (ctx: PredicateContext) => string | Promise<string>;
|
|
104
|
+
/**
|
|
105
|
+
* Plugin-registered predicate *handler*. Differs from {@link PredicateFn}
|
|
106
|
+
* only in that the first argument is the structured argument the user
|
|
107
|
+
* supplied under their custom `when.<key>` slot. Example:
|
|
108
|
+
*
|
|
109
|
+
* ```ts
|
|
110
|
+
* // user config
|
|
111
|
+
* when: { commitsAhead: { wrt: "origin/main", eq: 1 } }
|
|
112
|
+
*
|
|
113
|
+
* // plugin registration
|
|
114
|
+
* predicates: {
|
|
115
|
+
* commitsAhead: (args: { wrt: string; eq: number }, ctx) => { ... }
|
|
116
|
+
* }
|
|
117
|
+
* ```
|
|
118
|
+
*
|
|
119
|
+
* `args` is whatever the rule author put under that key - the handler is
|
|
120
|
+
* responsible for validating its shape. `ctx` is the same
|
|
121
|
+
* {@link PredicateContext} the escape-hatch form receives.
|
|
122
|
+
*
|
|
123
|
+
* Returns a {@link PredicateVerdict} (`true | false | "unknown"`).
|
|
124
|
+
* Pre-trinary handlers returning plain `boolean` remain source-compatible:
|
|
125
|
+
* `boolean` is a subtype of `PredicateVerdict`, so existing handlers assign
|
|
126
|
+
* unchanged. Handlers that need to surface walker-unknown state to the
|
|
127
|
+
* engine return the literal string `"unknown"`; the engine then applies
|
|
128
|
+
* the leaf's `onUnknown:` modifier (or, inside `not:`, the block-level
|
|
129
|
+
* `onUnknown:`) to produce the leaf's verdict for downstream composition.
|
|
130
|
+
*
|
|
131
|
+
* Throwing inside a handler is equivalent to returning `"unknown"`: the
|
|
132
|
+
* engine catches and treats the leaf as unknown, then applies the
|
|
133
|
+
* `onUnknown:` policy. Prefer explicit returns; the catch exists so a
|
|
134
|
+
* buggy handler can't silently fail-OPEN by skipping its rule.
|
|
135
|
+
*
|
|
136
|
+
* See ADR "Design → Rule schema" → PredicateHandler.
|
|
137
|
+
*/
|
|
138
|
+
export type PredicateHandler<A = unknown> = (args: A, ctx: PredicateContext) => PredicateVerdict | Promise<PredicateVerdict>;
|
|
139
|
+
/**
|
|
140
|
+
* Trinary verdict surfaced by predicate handlers. `true` / `false` are
|
|
141
|
+
* the definite answers; the literal string `"unknown"` signals the
|
|
142
|
+
* predicate could not resolve its value (typically because some piece
|
|
143
|
+
* of walker-tracked state — cwd, branch, etc. — wasn't statically
|
|
144
|
+
* resolvable). The engine then applies the leaf's `onUnknown:` policy
|
|
145
|
+
* (or, inside `not:`, the block-level policy) to project the trinary
|
|
146
|
+
* verdict back to a definite boolean for rule-level composition.
|
|
147
|
+
*/
|
|
148
|
+
export type PredicateVerdict = boolean | "unknown";
|
|
149
|
+
/**
|
|
150
|
+
* Predicate modifiers — optional fields that can be added to a
|
|
151
|
+
* predicate's spread form (outer leaf level) OR to the not-block top
|
|
152
|
+
* level. Single source of truth for what predicate authors and users
|
|
153
|
+
* can configure beyond the bare value. Adding a new modifier here
|
|
154
|
+
* propagates everywhere in the schema (every predicate's spread form,
|
|
155
|
+
* every not-block top level) and automatically reserves its key name
|
|
156
|
+
* via {@link ReservedPredicateKey}.
|
|
157
|
+
*/
|
|
158
|
+
export interface PredicateModifiers {
|
|
159
|
+
/**
|
|
160
|
+
* Walker-unknown policy. When the predicate's value can't be
|
|
161
|
+
* resolved at walker time (dynamic cwd / branch via `cd "$VAR"`,
|
|
162
|
+
* `git checkout $VAR`, etc.), this policy decides the predicate's
|
|
163
|
+
* verdict:
|
|
164
|
+
* - `"block"` (default): treat as fail-CLOSED — predicate fires.
|
|
165
|
+
* - `"allow"`: treat as fail-OPEN — predicate skips.
|
|
166
|
+
*
|
|
167
|
+
* At the leaf level (outer when-clause), this is per-predicate.
|
|
168
|
+
* At the not-block top level, this applies to ALL leaves in the
|
|
169
|
+
* not-block — leaf-level `onUnknown:` is forbidden inside `not:`
|
|
170
|
+
* (type-level error) so the user can't write the silent
|
|
171
|
+
* fail-OPEN `not: { cwd: P }` shape.
|
|
172
|
+
*/
|
|
173
|
+
onUnknown?: "allow" | "block";
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Default spread BASE (without modifiers) inferred from `Bare`'s shape:
|
|
177
|
+
* - `Bare extends object` → `Bare` directly (intersection at use site).
|
|
178
|
+
* - `Bare extends Patterns` → `{ pattern: Bare }` wrapper.
|
|
179
|
+
* - else (primitive) → `{ value: Bare }` wrapper.
|
|
180
|
+
*
|
|
181
|
+
* Note the order: object check FIRST so a pure-object predicate
|
|
182
|
+
* auto-detects to intersection (clean sibling-modifier UX). Pattern
|
|
183
|
+
* check second to capture the built-in `string | RegExp | array`
|
|
184
|
+
* family. Primitive fallback for `boolean`, `number`, etc.
|
|
185
|
+
*
|
|
186
|
+
* The tuple-wrap (`[Bare] extends [...]`) prevents distributive
|
|
187
|
+
* conditional behavior across union members of `Bare`.
|
|
188
|
+
*/
|
|
189
|
+
export type DefaultSpreadBase<Bare> = [Bare] extends [object] ? Bare : [Bare] extends [Patterns] ? {
|
|
190
|
+
pattern: Bare;
|
|
191
|
+
} : {
|
|
192
|
+
value: Bare;
|
|
193
|
+
};
|
|
194
|
+
/**
|
|
195
|
+
* Shape of a single entry in the {@link PiSteeringPredicates} registry.
|
|
196
|
+
* Each predicate declares its `bare` form and (optionally) an explicit
|
|
197
|
+
* `spreadBase` — the spread's object form WITHOUT modifiers.
|
|
198
|
+
*
|
|
199
|
+
* - `bare`: the value users write at the leaf (Pattern, boolean,
|
|
200
|
+
* number, etc.).
|
|
201
|
+
* - `SpreadBase` (param): the spread's object form WITHOUT modifiers;
|
|
202
|
+
* defaults to {@link DefaultSpreadBase} from `Bare`.
|
|
203
|
+
* - `spread` (derived at use site): `spreadBase & PredicateModifiers`
|
|
204
|
+
* — the form users write at the leaf to specify modifiers.
|
|
205
|
+
* Inner `not:` form omits modifiers (leaf-level `onUnknown:`
|
|
206
|
+
* inside `not:` is forbidden); outer leaf form intersects
|
|
207
|
+
* with `PredicateModifiers`.
|
|
208
|
+
*
|
|
209
|
+
* @see PredicateModifiers for the modifier surface available on every
|
|
210
|
+
* predicate's spread form.
|
|
211
|
+
* @see DefaultSpreadBase for how the SpreadBase auto-detects from Bare.
|
|
212
|
+
*/
|
|
213
|
+
export interface PredicateShape<Bare, SpreadBase = DefaultSpreadBase<Bare>> {
|
|
214
|
+
/**
|
|
215
|
+
* The bare value users write at the leaf (no wrapper, no modifiers).
|
|
216
|
+
*
|
|
217
|
+
* For primitive {@link Bare} types (Pattern, boolean, number) this
|
|
218
|
+
* is the shorthand authors reach for first — `cwd: /work/`,
|
|
219
|
+
* `isClean: true`, `commitsAhead: 2`. The {@link spreadBase} form
|
|
220
|
+
* mirrors the bare value as `{ value: Bare }` (or an explicit
|
|
221
|
+
* `SpreadBase` shape for mixed-bare predicates like `commitsAhead`'s
|
|
222
|
+
* comparator bag) when authors need to attach leaf-level modifiers
|
|
223
|
+
* via `& PredicateModifiers`.
|
|
224
|
+
*
|
|
225
|
+
* For object {@link Bare} types the bare form IS the object shape
|
|
226
|
+
* directly (no `value:` wrapper); the {@link spreadBase} adds
|
|
227
|
+
* modifier slots without changing the structural shape.
|
|
228
|
+
*/
|
|
229
|
+
bare: Bare;
|
|
230
|
+
/**
|
|
231
|
+
* The object form WITHOUT modifiers. Modifiers are added at use site
|
|
232
|
+
* via `& PredicateModifiers` (outer leaf level) or at the not-block
|
|
233
|
+
* top level (inside `not:`).
|
|
234
|
+
*/
|
|
235
|
+
spreadBase: SpreadBase;
|
|
236
|
+
}
|
|
237
|
+
declare global {
|
|
238
|
+
/**
|
|
239
|
+
* Plugin-registered predicate registry. Empty by default; plugins
|
|
240
|
+
* extend via TypeScript module augmentation (`declare global { interface
|
|
241
|
+
* PiSteeringPredicates { ... } }`) to register typed predicates with
|
|
242
|
+
* autocomplete + JSDoc.
|
|
243
|
+
*
|
|
244
|
+
* Keys must NOT collide with {@link ReservedPredicateKey} (the
|
|
245
|
+
* operator-field union plus modifier keys); the type-level filter
|
|
246
|
+
* via {@link Exclude} drops collisions silently, and the engine
|
|
247
|
+
* throws at plugin-registration time with a concrete error message
|
|
248
|
+
* pointing the plugin author at the collision.
|
|
249
|
+
*
|
|
250
|
+
* Do NOT add an index signature (e.g. `[k: string]: PredicateShape<unknown>`)
|
|
251
|
+
* to this interface — it would widen `keyof PiSteeringPredicates` to
|
|
252
|
+
* `string`, defeating the reserved-key filter (`Exclude<string,
|
|
253
|
+
* "not" | "onUnknown">` is just `string` again).
|
|
254
|
+
*
|
|
255
|
+
* @example Plugin author registering a typed predicate
|
|
256
|
+
* ```ts
|
|
257
|
+
* import type { Plugin, PredicateShape } from "@cad0p/pi-steering";
|
|
258
|
+
* import { workItemFormat } from "./predicates/work-item-format.ts";
|
|
259
|
+
* import type { WorkItemFormatArgs } from "./predicates/work-item-format.ts";
|
|
260
|
+
*
|
|
261
|
+
* declare global {
|
|
262
|
+
* interface PiSteeringPredicates {
|
|
263
|
+
* workItemFormat: PredicateShape<WorkItemFormatArgs>;
|
|
264
|
+
* }
|
|
265
|
+
* }
|
|
266
|
+
*
|
|
267
|
+
* const myPlugin = {
|
|
268
|
+
* name: "work-item",
|
|
269
|
+
* predicates: { workItemFormat },
|
|
270
|
+
* } as const satisfies Plugin;
|
|
271
|
+
* ```
|
|
272
|
+
*
|
|
273
|
+
* @see {@link PredicateShape} for the bare / spreadBase shape contract.
|
|
274
|
+
* @see {@link DefaultSpreadBase} for how spreadBase auto-detects from `bare`.
|
|
275
|
+
* @see {@link PredicateModifiers} for available leaf-level modifier fields.
|
|
276
|
+
* @see The `gitPlugin` declaration in `plugins/git/index.ts` for a
|
|
277
|
+
* multi-predicate registry block (branch, upstream, remote,
|
|
278
|
+
* isClean, hasStagedChanges, commitsAhead).
|
|
279
|
+
* @see `examples/work-item-plugin/` for an end-to-end external-plugin
|
|
280
|
+
* reference: handler module, predicate-bare-shape registration,
|
|
281
|
+
* and a worked rule + tests.
|
|
282
|
+
*/
|
|
283
|
+
interface PiSteeringPredicates {
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Operator fields on `TopLevelWhenClause`. Currently just `"not"`;
|
|
288
|
+
* future v0.2 may add `"or"` / `"and"` operators. Kept as a separate
|
|
289
|
+
* union so reserved-key derivation stays lockstep with operator
|
|
290
|
+
* additions.
|
|
291
|
+
*/
|
|
292
|
+
export type OperatorField = "not";
|
|
293
|
+
/**
|
|
294
|
+
* Reserved predicate keys derived from `OperatorField | keyof
|
|
295
|
+
* PredicateModifiers`. Plugin authors cannot register predicates with
|
|
296
|
+
* these names — they collide with the schema's `not?:` operator field
|
|
297
|
+
* and the `& PredicateModifiers` intersection on spread forms.
|
|
298
|
+
*
|
|
299
|
+
* Adding a new modifier to {@link PredicateModifiers} automatically
|
|
300
|
+
* reserves its key (lockstep via `keyof`); adding a new operator
|
|
301
|
+
* requires extending {@link OperatorField}.
|
|
302
|
+
*
|
|
303
|
+
* The type-level filter via {@link PluginPredicateKey} removes these
|
|
304
|
+
* names from the mapped types; the runtime constant
|
|
305
|
+
* `RESERVED_PREDICATE_KEYS` (in `evaluator-internals/predicates.ts`)
|
|
306
|
+
* mirrors this set so the engine throws at plugin-registration time
|
|
307
|
+
* with a concrete error message.
|
|
308
|
+
*/
|
|
309
|
+
export type ReservedPredicateKey = OperatorField | keyof PredicateModifiers;
|
|
310
|
+
/**
|
|
311
|
+
* Plugin-registered predicate keys with reserved names filtered out.
|
|
312
|
+
* Used as the value-position constraint on {@link OuterValue} /
|
|
313
|
+
* {@link InnerValue}, and as the `K & PluginPredicateKey`
|
|
314
|
+
* intersection narrowing inside {@link TopLevelWhenClause} /
|
|
315
|
+
* {@link TopLevelWhenClauseNoRecurse}. NOT used as the mapped-type
|
|
316
|
+
* iteration source — those mapped types iterate `keyof
|
|
317
|
+
* PiSteeringPredicates` directly with an `as`-filter, see their
|
|
318
|
+
* mapping-shape note for why.
|
|
319
|
+
*/
|
|
320
|
+
export type PluginPredicateKey = Exclude<keyof PiSteeringPredicates, ReservedPredicateKey>;
|
|
321
|
+
/**
|
|
322
|
+
* Outer leaf value: the bare form OR the spreadBase intersected with
|
|
323
|
+
* {@link PredicateModifiers}. Used at the top-level `when:` clause
|
|
324
|
+
* where each plugin-registered predicate accepts modifiers per-leaf.
|
|
325
|
+
*
|
|
326
|
+
* @see TopLevelWhenClause for the surface that consumes this mapped
|
|
327
|
+
* type.
|
|
328
|
+
*/
|
|
329
|
+
export type OuterValue<K extends PluginPredicateKey> = PiSteeringPredicates[K]["bare"] | (PiSteeringPredicates[K]["spreadBase"] & PredicateModifiers);
|
|
330
|
+
/**
|
|
331
|
+
* Inner leaf value (inside `not:`): bare form OR spreadBase WITHOUT
|
|
332
|
+
* modifiers. Modifiers live at the not-block top level — leaf-level
|
|
333
|
+
* modifiers inside `not:` are forbidden so the silent fail-OPEN
|
|
334
|
+
* `not: { cwd: P }` shape can't be reproduced via leaf-level
|
|
335
|
+
* `onUnknown:` placement.
|
|
336
|
+
*
|
|
337
|
+
* @see TopLevelWhenClauseNoRecurse for the surface that consumes this
|
|
338
|
+
* mapped type.
|
|
339
|
+
*/
|
|
340
|
+
export type InnerValue<K extends PluginPredicateKey> = PiSteeringPredicates[K]["bare"] | PiSteeringPredicates[K]["spreadBase"];
|
|
341
|
+
/**
|
|
342
|
+
* Built-in non-registry leaves attached to a {@link Rule.when}
|
|
343
|
+
* clause — outer flavor.
|
|
344
|
+
*
|
|
345
|
+
* These predicates ship with the engine itself (not via a plugin),
|
|
346
|
+
* so they aren't in {@link PiSteeringPredicates} but DO need to
|
|
347
|
+
* surface on {@link TopLevelWhenClause} as typed fields.
|
|
348
|
+
*
|
|
349
|
+
* The `Writes` generic threads through {@link defineConfig} so that
|
|
350
|
+
* `when.happened.event` / `when.happened.since` references are
|
|
351
|
+
* compile-time-checked against the union of declared `writes`
|
|
352
|
+
* arrays across plugins + observers.
|
|
353
|
+
*
|
|
354
|
+
* Currently three non-registry leaves: `happened?:`, `condition?:`,
|
|
355
|
+
* `cwd?:`. The shape is pinned in tests (a future widening — e.g.,
|
|
356
|
+
* adding a new built-in `tool?:` leaf — fails the type-pin and
|
|
357
|
+
* forces a deliberate decision).
|
|
358
|
+
*
|
|
359
|
+
* ## Outer / Inner split
|
|
360
|
+
*
|
|
361
|
+
* `cwd:`'s spread form differs depending on placement:
|
|
362
|
+
*
|
|
363
|
+
* - Outer (rule-level `when:`) — leaf-level `onUnknown?:` allowed,
|
|
364
|
+
* honored by the engine's `evaluateCwd` + `projectVerdict` flow.
|
|
365
|
+
* - Inner (inside `not:`) — leaf-level `onUnknown?:` forbidden
|
|
366
|
+
* (parity with registry-driven inner predicates per
|
|
367
|
+
* {@link InnerValue}). Walker-unknown cwd inside `not:` projects
|
|
368
|
+
* via the block-level `onUnknown:` modifier (default `"block"`).
|
|
369
|
+
*
|
|
370
|
+
* Two parallel interfaces formalize this split: this type
|
|
371
|
+
* (`BuiltInWhenLeavesOuter`) and {@link BuiltInWhenLeavesInner}. The
|
|
372
|
+
* legacy `BuiltInWhenLeaves` symbol is preserved as a deprecated
|
|
373
|
+
* alias to `Outer` for backward compatibility — new code should
|
|
374
|
+
* import the explicit Outer / Inner flavor.
|
|
375
|
+
*
|
|
376
|
+
* This is the outer-flavor interface. `cwd?:` accepts
|
|
377
|
+
* `Pattern | Pattern[] | { pattern, onUnknown? }` (leaf-level
|
|
378
|
+
* `onUnknown:` honored at the outer when-level via `evaluateCwd` /
|
|
379
|
+
* `projectVerdict`).
|
|
380
|
+
*
|
|
381
|
+
* @see BuiltInWhenLeavesInner for the parallel inner-flavor type
|
|
382
|
+
* used inside `not:`.
|
|
383
|
+
*/
|
|
384
|
+
export interface BuiltInWhenLeavesOuter<Writes extends string = string> {
|
|
385
|
+
/**
|
|
386
|
+
* Rule fires when the given `event` has NOT happened in the given
|
|
387
|
+
* scope. Typical usage: "block `cr` unless sync has happened" -
|
|
388
|
+
* `happened: { event: "rds-ws-sync-done", in: "agent_loop" }`.
|
|
389
|
+
*
|
|
390
|
+
* Scopes:
|
|
391
|
+
* - `"agent_loop"` - filter session entries by
|
|
392
|
+
* `entry.data._agentLoopIndex === ctx.agentLoopIndex`. The engine
|
|
393
|
+
* auto-injects that tag on every `appendEntry` write, so plugin
|
|
394
|
+
* authors don't have to remember to tag manually.
|
|
395
|
+
* - `"session"` - no scope filter. Any entry of `event` present
|
|
396
|
+
* in the session JSONL satisfies.
|
|
397
|
+
* - `"tool_call"` - only consider speculative entries synthesized
|
|
398
|
+
* for THIS tool_call's `&&`-chain. Real (persisted) entries are
|
|
399
|
+
* ignored entirely. Use when the rule requires the event to be
|
|
400
|
+
* CHAINED directly before the guarded command (e.g. `sync && cr`)
|
|
401
|
+
* rather than merely "somewhere this agent loop". Pairs naturally
|
|
402
|
+
* with observer `writes:` declarations on observers whose
|
|
403
|
+
* watch-matched refs produce speculative entries; no-op when no
|
|
404
|
+
* observer writes the event.
|
|
405
|
+
*
|
|
406
|
+
* Inversion: place inside `not` to flip the clause-level boolean -
|
|
407
|
+
* `not: { happened: { event, in } }` fires when the event HAS
|
|
408
|
+
* happened. See ADR §5.
|
|
409
|
+
*
|
|
410
|
+
* Optional `since` sentinel (temporal ordering): when present,
|
|
411
|
+
* `event` is considered "happened" only if its most-recent entry
|
|
412
|
+
* in scope is newer than the most-recent `since` entry in scope.
|
|
413
|
+
* If `since` has never been written, the clause behaves as if
|
|
414
|
+
* `since` were absent (simple presence check on `event`).
|
|
415
|
+
*
|
|
416
|
+
* Use for invalidation semantics: "rule fires when sync has not
|
|
417
|
+
* happened in this agent_loop, OR the last sync is older than the
|
|
418
|
+
* last upstream-fail." Pattern:
|
|
419
|
+
* `happened: { event: SYNC_DONE_EVENT, in: "agent_loop",
|
|
420
|
+
* since: UPSTREAM_FAILED_EVENT }`.
|
|
421
|
+
*
|
|
422
|
+
* Optional `notIn` (set subtraction over scopes): when present,
|
|
423
|
+
* entries in `notIn` scope are excluded from the `in`-scoped entry
|
|
424
|
+
* stream BEFORE the `ts_max` comparison runs. Typical use:
|
|
425
|
+
* `happened: { event, in: "agent_loop", notIn: "tool_call" }` -
|
|
426
|
+
* "happened in a prior tool_call in this agent loop". Excludes
|
|
427
|
+
* same-tool_call speculative entries so `someCmd && guardedCmd`
|
|
428
|
+
* can't bypass the rule via tool_call-scope speculative synthesis.
|
|
429
|
+
*
|
|
430
|
+
* Distinct from the clause-level {@link TopLevelWhenClause.not},
|
|
431
|
+
* which is boolean negation of a sub-clause. `notIn` is set
|
|
432
|
+
* subtraction; separate keyword so the two operators can't be
|
|
433
|
+
* confused.
|
|
434
|
+
*
|
|
435
|
+
* Invalid scope combinations throw at evaluation time with the
|
|
436
|
+
* rule name prefixed:
|
|
437
|
+
* - Supersets (e.g. `in: "agent_loop", notIn: "session"`) - the
|
|
438
|
+
* subtraction is always empty.
|
|
439
|
+
* - Identicals (`notIn === in`) - the subtraction is always empty.
|
|
440
|
+
*
|
|
441
|
+
* Compile-time constraint: inside {@link defineConfig}, both the
|
|
442
|
+
* `event` and `since` fields are narrowed to the union of all
|
|
443
|
+
* `writes` declared across plugin rules, plugin observers, user
|
|
444
|
+
* rules, and user observers. Typos become compile errors. Outside
|
|
445
|
+
* `defineConfig` the `Writes` parameter defaults to `string` so the
|
|
446
|
+
* check is skipped.
|
|
447
|
+
*/
|
|
448
|
+
happened?: {
|
|
449
|
+
event: Writes;
|
|
450
|
+
in: "agent_loop" | "session" | "tool_call";
|
|
451
|
+
since?: Writes;
|
|
452
|
+
notIn?: "agent_loop" | "session" | "tool_call";
|
|
453
|
+
};
|
|
454
|
+
/**
|
|
455
|
+
* Escape-hatch predicate for one-off logic. Prefer plugin-registered
|
|
456
|
+
* predicates when the logic is reusable; use `condition` for
|
|
457
|
+
* genuinely local checks that don't warrant a plugin.
|
|
458
|
+
*
|
|
459
|
+
* Throws (sync or rejected promise) are caught and treated as
|
|
460
|
+
* `"unknown"`. Outer-level `condition:` is bare-`PredicateFn`-typed
|
|
461
|
+
* (no spread shape), so the projection always uses the default
|
|
462
|
+
* `"block"` policy: a throwing condition fires the rule fail-CLOSED.
|
|
463
|
+
* Authors needing fail-OPEN wrap inside
|
|
464
|
+
* `not: { condition: fn, onUnknown: "allow" }` (block-level
|
|
465
|
+
* modifier) OR catch the throw inside the callback body. Mirrors
|
|
466
|
+
* the plugin-handler exception contract.
|
|
467
|
+
*/
|
|
468
|
+
condition?: PredicateFn;
|
|
469
|
+
/**
|
|
470
|
+
* Constrain the rule to commands whose *effective* cwd matches
|
|
471
|
+
* the given pattern. For bash, the walker's `cwdTracker` resolves
|
|
472
|
+
* the effective cwd per extracted command (so
|
|
473
|
+
* `cd ~/personal && git commit --amend` evaluates against
|
|
474
|
+
* `~/personal`). For write / edit, the session cwd is used directly.
|
|
475
|
+
*
|
|
476
|
+
* Bare form: a single {@link Pattern} or an OR-of-patterns array.
|
|
477
|
+
* Spread form: `{ pattern, onUnknown? }` — the object lets authors
|
|
478
|
+
* opt into `onUnknown: "allow"` when a command's cwd can't be
|
|
479
|
+
* statically resolved (e.g. `cd $VAR && ...`). Default is `"block"`
|
|
480
|
+
* — fail-closed.
|
|
481
|
+
*
|
|
482
|
+
* Array form (`Pattern[]` or `{ pattern: Pattern[]; onUnknown? }`)
|
|
483
|
+
* matches OR-of-patterns: the predicate fires when the resolved cwd
|
|
484
|
+
* matches ANY of the listed patterns. Empty arrays are invalid (rule
|
|
485
|
+
* skips); arrays containing non-Pattern values are invalid (rule
|
|
486
|
+
* skips). Array form sugars vault-path or workspace-tree exemptions:
|
|
487
|
+
*
|
|
488
|
+
* ```ts
|
|
489
|
+
* when: { cwd: [/\/Goldmine\//, /\/\.cache\/napkin-distill\//] }
|
|
490
|
+
* when: { cwd: { pattern: [/\.test$/, /\.spec$/], onUnknown: "allow" } }
|
|
491
|
+
* ```
|
|
492
|
+
*
|
|
493
|
+
* `cwd:` is the sole walker-tied built-in leaf; all other dimensions
|
|
494
|
+
* (`branch`, `upstream`, ...) come from plugins. It lives on
|
|
495
|
+
* {@link BuiltInWhenLeaves} (not the registry) so authors can write
|
|
496
|
+
* `when: { cwd: /work/ }` against pi-steering core without needing
|
|
497
|
+
* gitPlugin's module augmentation in scope.
|
|
498
|
+
*
|
|
499
|
+
* See ADR "Design → Override default and `onUnknown`".
|
|
500
|
+
*/
|
|
501
|
+
cwd?: Pattern | Pattern[] | {
|
|
502
|
+
pattern: Pattern | Pattern[];
|
|
503
|
+
onUnknown?: "allow" | "block";
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Inner-flavor non-registry built-in leaves. Lives on
|
|
508
|
+
* {@link TopLevelWhenClauseNoRecurse} (the body of `not:`). `cwd?:`
|
|
509
|
+
* accepts `Pattern | Pattern[] | { pattern }` — NO leaf-level
|
|
510
|
+
* `onUnknown?:`. Modifiers live at the not-block level via
|
|
511
|
+
* `& PredicateModifiers`, matching the constraint registry-driven
|
|
512
|
+
* inner predicates already enforce via `InnerValue<K>`.
|
|
513
|
+
*
|
|
514
|
+
* `happened?:` and `condition?:` are identical to
|
|
515
|
+
* {@link BuiltInWhenLeavesOuter}; only `cwd:` differs. The engine
|
|
516
|
+
* reads block-level `onUnknown:` inside `not:` regardless of leaf
|
|
517
|
+
* shape, so this type formalizes that constraint at the authoring
|
|
518
|
+
* surface (preventing the silent fail-OPEN class where leaf-level
|
|
519
|
+
* `onUnknown:` looks meaningful but is ignored at runtime).
|
|
520
|
+
*
|
|
521
|
+
* @see BuiltInWhenLeavesOuter for the design rationale, the
|
|
522
|
+
* Outer/Inner split, and the parallel outer-flavor type.
|
|
523
|
+
*/
|
|
524
|
+
export interface BuiltInWhenLeavesInner<Writes extends string = string> {
|
|
525
|
+
/** Identical to {@link BuiltInWhenLeavesOuter.happened}. */
|
|
526
|
+
happened?: BuiltInWhenLeavesOuter<Writes>["happened"];
|
|
527
|
+
/** Identical to {@link BuiltInWhenLeavesOuter.condition}. */
|
|
528
|
+
condition?: BuiltInWhenLeavesOuter<Writes>["condition"];
|
|
529
|
+
/**
|
|
530
|
+
* Same `cwd:` semantics as {@link BuiltInWhenLeavesOuter.cwd} but
|
|
531
|
+
* the spread form's `onUnknown?:` is dropped — modifiers live at
|
|
532
|
+
* the not-block level via `& PredicateModifiers`. Walker-unknown
|
|
533
|
+
* cwd inside `not:` projects via the block-level `onUnknown:`
|
|
534
|
+
* (default `"block"` = fail-CLOSED, rule fires).
|
|
535
|
+
*/
|
|
536
|
+
cwd?: Pattern | Pattern[] | {
|
|
537
|
+
pattern: Pattern | Pattern[];
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* Backward-compatible alias for {@link BuiltInWhenLeavesOuter}.
|
|
542
|
+
*
|
|
543
|
+
* Retained so external code importing `BuiltInWhenLeaves` (and the
|
|
544
|
+
* public-surface shape pin in `not-block-onunknown.test.ts`) keeps
|
|
545
|
+
* working after the Outer/Inner split.
|
|
546
|
+
*
|
|
547
|
+
* @deprecated Use {@link BuiltInWhenLeavesOuter} for outer-level
|
|
548
|
+
* authoring or {@link BuiltInWhenLeavesInner} for `not:` block
|
|
549
|
+
* bodies. This alias is retained for backward compatibility and may
|
|
550
|
+
* be removed in a future release.
|
|
551
|
+
*/
|
|
552
|
+
export type BuiltInWhenLeaves<Writes extends string = string> = BuiltInWhenLeavesOuter<Writes>;
|
|
553
|
+
/**
|
|
554
|
+
* Top-level when-clause attached to a {@link Rule}. Each
|
|
555
|
+
* plugin-registered predicate (filtered for reserved keys) gets a
|
|
556
|
+
* leaf-level field accepting the bare or spread form. The `not?:`
|
|
557
|
+
* operator allows one level of negation (no recursion).
|
|
558
|
+
*
|
|
559
|
+
* Generic over `Writes` so the built-in `happened?:` leaf's `event` /
|
|
560
|
+
* `since` references narrow to the union of declared `writes` strings
|
|
561
|
+
* threaded through by {@link defineConfig}.
|
|
562
|
+
*
|
|
563
|
+
* Mapping shape note: the constraint is `keyof PiSteeringPredicates`
|
|
564
|
+
* with an `as`-filter excluding {@link ReservedPredicateKey}, rather
|
|
565
|
+
* than the pre-computed alias `[K in PluginPredicateKey]`. Both shapes
|
|
566
|
+
* produce the same keyset, but only the homomorphic-with-filter form
|
|
567
|
+
* propagates JSDoc on hover from `PiSteeringPredicates.<key>` source
|
|
568
|
+
* declarations onto the synthesized field. The `& PluginPredicateKey`
|
|
569
|
+
* intersection in the value position narrows `K` back to the
|
|
570
|
+
* constraint expected by {@link OuterValue}. Keep both the constraint
|
|
571
|
+
* and the `as`-filter inlined — extracting either to a type alias
|
|
572
|
+
* silently regresses the propagation.
|
|
573
|
+
*
|
|
574
|
+
* Hover-on-`defineConfig`-inline-rules caveat: passing rule literals
|
|
575
|
+
* directly into `defineConfig({ rules: [{ ... }] })` bypasses the
|
|
576
|
+
* mapped-type linkage — the `const R extends readonly Rule[]`
|
|
577
|
+
* signature narrows the literal to its `const`-inferred shape.
|
|
578
|
+
* Factor rules out (`const myRule = { ... } as const satisfies Rule`)
|
|
579
|
+
* when hover-rich authoring matters; see the {@link defineConfig} JSDoc.
|
|
580
|
+
*
|
|
581
|
+
* @see TopLevelWhenClauseNoRecurse for the body of `not:`.
|
|
582
|
+
* @see PredicateModifiers for available leaf-level modifier fields.
|
|
583
|
+
* @see BuiltInWhenLeaves for the engine's non-registry leaf set.
|
|
584
|
+
*/
|
|
585
|
+
export type TopLevelWhenClause<Writes extends string = string> = {
|
|
586
|
+
[K in keyof PiSteeringPredicates as K extends ReservedPredicateKey ? never : K]?: OuterValue<K & PluginPredicateKey>;
|
|
587
|
+
} & BuiltInWhenLeavesOuter<Writes> & {
|
|
588
|
+
/**
|
|
589
|
+
* Logical NOT: rule fires when the inner predicates' AND is false.
|
|
590
|
+
*
|
|
591
|
+
* Multi-leaf semantics: leaves AND together with Kleene 3-valued
|
|
592
|
+
* logic. Walker-unknown leaves resolve via the block-level
|
|
593
|
+
* `onUnknown:` modifier (default `"block"` = fail-CLOSED, rule
|
|
594
|
+
* fires).
|
|
595
|
+
*
|
|
596
|
+
* No leaf-level `onUnknown:` here (forbidden at type level —
|
|
597
|
+
* modifiers live at the not-block level). No `not:` recursion
|
|
598
|
+
* (forbidden at type level — semantically equivalent to the
|
|
599
|
+
* unwrapped form). Nested `not:` is also rejected at runtime by
|
|
600
|
+
* `validateWhenClauseShape` for JSON / `as any` escape hatches.
|
|
601
|
+
*
|
|
602
|
+
* @see TopLevelWhenClauseNoRecurse
|
|
603
|
+
* @see PredicateModifiers
|
|
604
|
+
*/
|
|
605
|
+
not?: TopLevelWhenClauseNoRecurse<Writes>;
|
|
606
|
+
};
|
|
607
|
+
/**
|
|
608
|
+
* Body of a `not:` block: predicates with their bare / spreadBase
|
|
609
|
+
* forms (NO leaf-level modifiers — modifiers live at this block's top
|
|
610
|
+
* level via `& PredicateModifiers`). No nested `not:` (no recursion).
|
|
611
|
+
*
|
|
612
|
+
* Generic over `Writes` so the built-in `happened?:` leaf inherits
|
|
613
|
+
* the same compile-time event-narrowing as the outer level.
|
|
614
|
+
*
|
|
615
|
+
* Same homomorphic-with-filter mapping shape as
|
|
616
|
+
* {@link TopLevelWhenClause} (and the same `defineConfig`-inline
|
|
617
|
+
* caveat). See the mapping-shape note there for the rationale.
|
|
618
|
+
*
|
|
619
|
+
* @see TopLevelWhenClause for the rule-attached when-clause.
|
|
620
|
+
* @see PredicateModifiers for block-level modifier fields.
|
|
621
|
+
*/
|
|
622
|
+
export type TopLevelWhenClauseNoRecurse<Writes extends string = string> = {
|
|
623
|
+
[K in keyof PiSteeringPredicates as K extends ReservedPredicateKey ? never : K]?: InnerValue<K & PluginPredicateKey>;
|
|
624
|
+
} & BuiltInWhenLeavesInner<Writes> & PredicateModifiers;
|
|
625
|
+
/**
|
|
626
|
+
* Type-erased alias for {@link PredicateHandler} used at registry
|
|
627
|
+
* boundaries (notably {@link Plugin.predicates}).
|
|
628
|
+
*
|
|
629
|
+
* TypeScript treats function-argument types as contravariant: a
|
|
630
|
+
* `PredicateHandler<CommitsAheadArgs>` is **not** assignable to a
|
|
631
|
+
* `PredicateHandler<unknown>` because the handler needs to *accept*
|
|
632
|
+
* `unknown`, while the specialized handler only accepts a narrower
|
|
633
|
+
* shape. Using `any` at the registry slot leverages TS's bivariance
|
|
634
|
+
* fallback — specifically-typed handlers assign without a cast, and
|
|
635
|
+
* the engine's generic call site stays safe because it passes the
|
|
636
|
+
* matching `when.<name>` value straight through to the handler
|
|
637
|
+
* (the handler already validates its own arg shape).
|
|
638
|
+
*
|
|
639
|
+
* Prefer this alias at any `Record<string, PredicateHandler<…>>`
|
|
640
|
+
* boundary where the value shape is per-key heterogeneous.
|
|
641
|
+
*
|
|
642
|
+
* ## Write-through registry slot, not a safe read type
|
|
643
|
+
*
|
|
644
|
+
* This alias exists so heterogeneous handler maps accept typed
|
|
645
|
+
* handlers cast-free on the WRITE side (plugin author stuffs a
|
|
646
|
+
* `PredicateHandler<FooArgs>` into `Plugin.predicates`). On the READ
|
|
647
|
+
* side (a consumer iterating `plugin.predicates`, or a decorator /
|
|
648
|
+
* middleware layering over a plugin's handlers) the retrieved value
|
|
649
|
+
* carries `args: any` — no compile-time narrowing. Consumers that
|
|
650
|
+
* want typed reads should narrow back to `PredicateHandler<TArgs>`
|
|
651
|
+
* at their call site.
|
|
652
|
+
*
|
|
653
|
+
* ## Handler authors: declare `PredicateHandler<YourArgs>`, not this
|
|
654
|
+
*
|
|
655
|
+
* Do NOT use `AnyPredicateHandler` as the type annotation for a
|
|
656
|
+
* handler declaration:
|
|
657
|
+
*
|
|
658
|
+
* // wrong — `args` is `any`, no narrowing inside the body.
|
|
659
|
+
* const myHandler: AnyPredicateHandler = (args, ctx) => { ... };
|
|
660
|
+
*
|
|
661
|
+
* // right — narrow `args` at the declaration; the result still
|
|
662
|
+
* // assigns cast-free into `Plugin.predicates`.
|
|
663
|
+
* const myHandler: PredicateHandler<MyArgs> = (args, ctx) => { ... };
|
|
664
|
+
*
|
|
665
|
+
* Also note: because `any` at the boundary disables compile-time
|
|
666
|
+
* narrowing on the engine's CALL site too, typed handlers MUST
|
|
667
|
+
* still validate their own `args` shape at the top of the function
|
|
668
|
+
* body. The engine passes the verbatim `when.<name>` value, so TS
|
|
669
|
+
* can't protect you from a user writing
|
|
670
|
+
* `when: { myPredicate: "not-the-shape-you-expected" }`.
|
|
671
|
+
* (See `isClean`'s `if (typeof args !== "boolean") return false`
|
|
672
|
+
* pattern for the canonical guard.)
|
|
673
|
+
*/
|
|
674
|
+
export type AnyPredicateHandler = PredicateHandler<any>;
|
|
675
|
+
/**
|
|
676
|
+
* Legacy v0.0.x predicate block; see {@link TopLevelWhenClause} for
|
|
677
|
+
* the authoring surface attached to {@link Rule.when}.
|
|
678
|
+
*
|
|
679
|
+
* The legacy interface is retained as an internal type for the JSON
|
|
680
|
+
* v1→v2 path in `compat.ts`, where the loose `[customKey: string]:
|
|
681
|
+
* unknown` index signature is needed to accept arbitrary plugin keys
|
|
682
|
+
* during deserialization. Plugin authors should NOT type their
|
|
683
|
+
* helpers against `WhenClause` — use {@link TopLevelWhenClause} for
|
|
684
|
+
* outer when-clauses or {@link TopLevelWhenClauseNoRecurse} for
|
|
685
|
+
* not-block bodies (both are registry-driven and enforce the
|
|
686
|
+
* five compile-time constraints documented on {@link BaseRule.when}).
|
|
687
|
+
*
|
|
688
|
+
* Note: even though this legacy interface permits `not: { not: ... }`
|
|
689
|
+
* recursion at the type level, the engine's runtime
|
|
690
|
+
* `validateWhenClauseShape` rejects nested-`not` shapes at
|
|
691
|
+
* `buildEvaluator` time. Authors smuggling depth-2 recursion through
|
|
692
|
+
* JSON v1 / `as any` casts hit the runtime guard, not silent
|
|
693
|
+
* acceptance.
|
|
694
|
+
*
|
|
695
|
+
* @deprecated Internal v1-compat type. Use {@link TopLevelWhenClause}
|
|
696
|
+
* at authoring sites; this interface is preserved only
|
|
697
|
+
* for the JSON v1→v2 conversion path in `compat.ts`.
|
|
698
|
+
*/
|
|
699
|
+
export interface WhenClause<Writes extends string = string> {
|
|
700
|
+
/**
|
|
701
|
+
* @deprecated Use {@link TopLevelWhenClause}'s `cwd?:` leaf (lifted
|
|
702
|
+
* onto {@link BuiltInWhenLeaves}). Retained for v1
|
|
703
|
+
* JSON→v2 conversion compatibility only.
|
|
704
|
+
* @see BuiltInWhenLeaves.cwd
|
|
705
|
+
*/
|
|
706
|
+
cwd?: Pattern | Pattern[] | {
|
|
707
|
+
pattern: Pattern | Pattern[];
|
|
708
|
+
onUnknown?: "allow" | "block";
|
|
709
|
+
};
|
|
710
|
+
/**
|
|
711
|
+
* @deprecated Use {@link TopLevelWhenClause}'s `happened?:` leaf
|
|
712
|
+
* (lifted onto {@link BuiltInWhenLeaves}). Retained for
|
|
713
|
+
* v1 JSON→v2 conversion compatibility only.
|
|
714
|
+
* @see BuiltInWhenLeaves.happened for the canonical semantics
|
|
715
|
+
* (scopes, `since`, `notIn`, runtime errors).
|
|
716
|
+
*/
|
|
717
|
+
happened?: {
|
|
718
|
+
event: Writes;
|
|
719
|
+
in: "agent_loop" | "session" | "tool_call";
|
|
720
|
+
since?: Writes;
|
|
721
|
+
notIn?: "agent_loop" | "session" | "tool_call";
|
|
722
|
+
};
|
|
723
|
+
/**
|
|
724
|
+
* @deprecated Use {@link TopLevelWhenClause}'s `not?:` operator field.
|
|
725
|
+
*/
|
|
726
|
+
not?: WhenClause<Writes>;
|
|
727
|
+
/**
|
|
728
|
+
* @deprecated Use {@link TopLevelWhenClause}'s `condition?:` leaf
|
|
729
|
+
* (lifted onto {@link BuiltInWhenLeaves}). Retained for
|
|
730
|
+
* v1 JSON→v2 conversion compatibility only.
|
|
731
|
+
* @see BuiltInWhenLeaves.condition
|
|
732
|
+
*/
|
|
733
|
+
condition?: PredicateFn;
|
|
734
|
+
/**
|
|
735
|
+
* @deprecated v1-compat loose index signature — plugin-registered
|
|
736
|
+
* predicates now live on {@link PiSteeringPredicates}
|
|
737
|
+
* with per-key compile-time typing via
|
|
738
|
+
* {@link OuterValue} / {@link InnerValue}. Retained for
|
|
739
|
+
* v1 JSON→v2 conversion compatibility only.
|
|
740
|
+
*/
|
|
741
|
+
[customKey: string]: Pattern | Pattern[] | PredicateFn | WhenClause | {
|
|
742
|
+
pattern: Pattern;
|
|
743
|
+
onUnknown?: "allow" | "block";
|
|
744
|
+
} | {
|
|
745
|
+
pattern: Pattern | Pattern[];
|
|
746
|
+
onUnknown?: "allow" | "block";
|
|
747
|
+
} | unknown;
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* Fields common to every tool-specific rule variant.
|
|
751
|
+
*
|
|
752
|
+
* `BaseRule` is the shared slice - everything except the `tool`
|
|
753
|
+
* discriminant and the tool-specific {@link BashRule.field} /
|
|
754
|
+
* {@link WriteRule.field} / {@link EditRule.field} sub-unions. The
|
|
755
|
+
* exported user-facing type is {@link Rule}, the discriminated union
|
|
756
|
+
* over the three tool variants; authors should reach for `Rule`
|
|
757
|
+
* unless they're writing generic rule-handling code that already
|
|
758
|
+
* knows the tool at its call site.
|
|
759
|
+
*
|
|
760
|
+
* Generic parameter `ObsName` constrains the string form of the
|
|
761
|
+
* {@link observer} field. {@link defineConfig} threads through the union
|
|
762
|
+
* of observer names gathered from plugins + inline observers, producing
|
|
763
|
+
* compile-time errors on typos. When authoring rules outside
|
|
764
|
+
* `defineConfig` (with plain `satisfies SteeringConfig`), the default
|
|
765
|
+
* `string` flows through and cross-reference checking is skipped.
|
|
766
|
+
*
|
|
767
|
+
* See ADR "Design → Rule schema".
|
|
768
|
+
*/
|
|
769
|
+
export interface BaseRule<ObsName extends string = string, Writes extends string = string> {
|
|
770
|
+
/** Unique rule identifier. Used in override comments and audit logs. */
|
|
771
|
+
name: string;
|
|
772
|
+
/**
|
|
773
|
+
* Main match predicate. See {@link Pattern}. The rule fires only
|
|
774
|
+
* if this matches the chosen `field` value (for bash, the
|
|
775
|
+
* AST-extracted command string per ref).
|
|
776
|
+
*/
|
|
777
|
+
pattern: Pattern;
|
|
778
|
+
/**
|
|
779
|
+
* Optional extra AND predicate - when provided, the rule fires
|
|
780
|
+
* only if this also matches. Accepts a pattern or a function so
|
|
781
|
+
* plugins can layer structured checks on top of the main match.
|
|
782
|
+
*/
|
|
783
|
+
requires?: Pattern | PredicateFn;
|
|
784
|
+
/**
|
|
785
|
+
* Exemption predicate - when provided and matches, the rule does
|
|
786
|
+
* NOT fire. Same shape choice as {@link requires}.
|
|
787
|
+
*/
|
|
788
|
+
unless?: Pattern | PredicateFn;
|
|
789
|
+
/**
|
|
790
|
+
* Composable predicate block. See {@link TopLevelWhenClause}.
|
|
791
|
+
*
|
|
792
|
+
* `Writes` is the union of session-entry event literals the rule's
|
|
793
|
+
* `when.happened.event` is allowed to reference. Threaded through by
|
|
794
|
+
* {@link defineConfig} from all declared `writes` arrays in scope.
|
|
795
|
+
*
|
|
796
|
+
* The five compile-time constraints from the not-block onUnknown
|
|
797
|
+
* design land here:
|
|
798
|
+
* 1. Each plugin-registered predicate is shape-checked against
|
|
799
|
+
* its `PiSteeringPredicates[K]` registry entry (bare /
|
|
800
|
+
* spreadBase). Typos / unknown predicates surface as
|
|
801
|
+
* compile errors at the rule definition.
|
|
802
|
+
* 2. Reserved keys (`not`, `onUnknown`, plus future modifiers)
|
|
803
|
+
* are dropped from the registry-driven mapped type via
|
|
804
|
+
* {@link PluginPredicateKey} so a plugin author can't shadow
|
|
805
|
+
* the operator/modifier surface.
|
|
806
|
+
* 3. Leaf-level `onUnknown:` inside `not:` is forbidden (the
|
|
807
|
+
* inner mapped type uses {@link InnerValue} which excludes
|
|
808
|
+
* modifiers — those live at the not-block top level).
|
|
809
|
+
* 4. `not: not:` recursion is forbidden
|
|
810
|
+
* ({@link TopLevelWhenClauseNoRecurse} has no `not?:` field).
|
|
811
|
+
* Belt-and-suspenders runtime guard in
|
|
812
|
+
* {@link validateWhenClauseShape} catches JSON / `as any`
|
|
813
|
+
* escape hatches.
|
|
814
|
+
* 5. Rule-level `onUnknown:` is forbidden
|
|
815
|
+
* ({@link TopLevelWhenClause} doesn't intersect with
|
|
816
|
+
* {@link PredicateModifiers}; only the inner `not:` body does).
|
|
817
|
+
*/
|
|
818
|
+
when?: TopLevelWhenClause<Writes>;
|
|
819
|
+
/**
|
|
820
|
+
* Message shown to the agent when blocked.
|
|
821
|
+
*
|
|
822
|
+
* A plain string is the most common shape and should be actionable
|
|
823
|
+
* (e.g. "Use `git commit --no-verify` to bypass"). The evaluator
|
|
824
|
+
* prefixes every block reason with `[steering:<rule>@<source>] `
|
|
825
|
+
* so the agent sees which rule fired and where it came from
|
|
826
|
+
* (ADR §11).
|
|
827
|
+
*
|
|
828
|
+
* A {@link ReasonFn} is invoked with the same
|
|
829
|
+
* {@link PredicateContext} the predicates saw. Use the function
|
|
830
|
+
* form when the reason text depends on runtime state - e.g. the
|
|
831
|
+
* walker's effective cwd, a resolved branch name, or a count
|
|
832
|
+
* pulled from `ctx.findEntries`. The evaluator awaits the return
|
|
833
|
+
* and applies the source-tag prefix identically to the string form.
|
|
834
|
+
*
|
|
835
|
+
* Fail-safe on throw: if the reason function throws synchronously
|
|
836
|
+
* or its returned promise rejects, the evaluator logs the error
|
|
837
|
+
* with `console.warn` and emits a fallback message
|
|
838
|
+
* (`[steering:<rule>@<source>] (reason failed to format; see log)`).
|
|
839
|
+
* The block verdict still lands - a broken reason doesn't release
|
|
840
|
+
* the rule's guard or leak raw error text to the LLM.
|
|
841
|
+
*
|
|
842
|
+
* Tag→body separator is paragraph-aware: a body containing `\n\n`
|
|
843
|
+
* (or its CRLF equivalent `\r\n\r\n`, defensive against bodies
|
|
844
|
+
* imported from Windows line-ending sources — CRLF templating
|
|
845
|
+
* layers, hand-typed Windows-IDE strings) renders with the
|
|
846
|
+
* `[steering:...]` tag on its own line followed by a paragraph
|
|
847
|
+
* break (`\n\n`); otherwise the tag and body share a single
|
|
848
|
+
* space-separated line. The trigger is double-newline
|
|
849
|
+
* specifically — single `\n` characters inside an otherwise
|
|
850
|
+
* single-paragraph body keep the single-space layout. The emitted
|
|
851
|
+
* separator is always normalized to `\n\n` regardless of which
|
|
852
|
+
* form (`\n\n` or `\r\n\r\n`) triggered it. Multi-paragraph
|
|
853
|
+
* reasons get the prefix-on-its-own-line layout automatically
|
|
854
|
+
* without the rule author managing leading whitespace.
|
|
855
|
+
*
|
|
856
|
+
* Body→override-hint separator mirrors the same paragraph-aware
|
|
857
|
+
* rule. Single-paragraph bodies keep a single-space prefix on the
|
|
858
|
+
* override hint (byte-identical to the pre-paragraph-aware
|
|
859
|
+
* rendering); multi-paragraph bodies promote the override hint
|
|
860
|
+
* to its own paragraph (`${body}\n\n${hint}`) so a safety
|
|
861
|
+
* paragraph stays visually standalone rather than running on
|
|
862
|
+
* into an inline "To override" sentence.
|
|
863
|
+
*/
|
|
864
|
+
reason: string | ReasonFn;
|
|
865
|
+
/**
|
|
866
|
+
* If `true`, no override escape hatch. If `false`, override always
|
|
867
|
+
* allowed. Omitted: falls back to
|
|
868
|
+
* {@link SteeringConfig.defaultNoOverride} (defaults to `true` -
|
|
869
|
+
* fail-closed).
|
|
870
|
+
*/
|
|
871
|
+
noOverride?: boolean;
|
|
872
|
+
/**
|
|
873
|
+
* Observer to attach to this rule. The observer fires on matching
|
|
874
|
+
* `tool_result` events and can record per-turn state the rule
|
|
875
|
+
* consults via {@link PredicateContext.findEntries}.
|
|
876
|
+
*
|
|
877
|
+
* Either an inline {@link Observer} or a string referencing an
|
|
878
|
+
* observer registered on a plugin or at the config's top level.
|
|
879
|
+
* String references are constrained to the union of observer names
|
|
880
|
+
* known at {@link defineConfig} call sites (typo → compile error).
|
|
881
|
+
*/
|
|
882
|
+
observer?: Observer | ObsName;
|
|
883
|
+
/**
|
|
884
|
+
* Session-entry custom types this rule's {@link onFire} may write.
|
|
885
|
+
*
|
|
886
|
+
* **Compile-time effect (via {@link defineConfig}):** the union of
|
|
887
|
+
* all `writes` literals declared across plugin rules, plugin
|
|
888
|
+
* observers, user rules, and user observers constrains the `event`
|
|
889
|
+
* field of every {@link BuiltInWhenLeavesOuter.happened} inside the same config.
|
|
890
|
+
* Declaring a write here makes it referenceable from
|
|
891
|
+
* `when.happened.event` anywhere in that config; omitting it leaves
|
|
892
|
+
* the string out of the union and downstream references to it are
|
|
893
|
+
* rejected as typos.
|
|
894
|
+
*
|
|
895
|
+
* **Authoring pattern.** Enforcement depends on TypeScript preserving
|
|
896
|
+
* the literal types of your `writes` arrays. Use one of:
|
|
897
|
+
* - `as const satisfies Rule` on a rule object literal, OR
|
|
898
|
+
* - `const satisfies Rule` on an object literal, OR
|
|
899
|
+
* - declaring the rule INSIDE the `defineConfig({ rules: [...] })`
|
|
900
|
+
* call so inference flows directly through the `const P`, `const R`
|
|
901
|
+
* generics.
|
|
902
|
+
*
|
|
903
|
+
* **Footgun: bare `: Rule` / `: Observer` / `: Plugin` annotations
|
|
904
|
+
* widen the literal `writes` array to `readonly string[]`. The engine
|
|
905
|
+
* can no longer project string-literal members, so `AllWrites`
|
|
906
|
+
* collapses to `never` - meaning EVERY `when.happened.event`
|
|
907
|
+
* reference in the config is rejected as a typo, not silently
|
|
908
|
+
* accepted.
|
|
909
|
+
*
|
|
910
|
+
* **Runtime effect:** none. `writes` is purely documentation +
|
|
911
|
+
* type-level plumbing - the engine does NOT verify that `onFire`
|
|
912
|
+
* only calls `ctx.appendEntry` with declared types.
|
|
913
|
+
*
|
|
914
|
+
* **Opt-out:** authors who build their config via
|
|
915
|
+
* `satisfies SteeringConfig` instead of `defineConfig` lose the
|
|
916
|
+
* compile-time check - the `SteeringConfig` shape defaults the
|
|
917
|
+
* {@link Rule} generics to `string`, so `when.happened.event` is
|
|
918
|
+
* unconstrained. `defineConfig` is the entry point that enforces.
|
|
919
|
+
*
|
|
920
|
+
* The wider warning - "name" / "plugin" literals widening to
|
|
921
|
+
* `string` - causes the opposite failure: typos in `disabledRules`
|
|
922
|
+
* / `disabledPlugins` start compiling silently. Always use
|
|
923
|
+
* `as const satisfies` for reusable constants.
|
|
924
|
+
*/
|
|
925
|
+
writes?: readonly string[];
|
|
926
|
+
/**
|
|
927
|
+
* Side-effect hook invoked when the rule decides to fire (all
|
|
928
|
+
* predicates passed) and BEFORE the block verdict is returned.
|
|
929
|
+
*
|
|
930
|
+
* Use for self-marking patterns where the rule's fire IS the event
|
|
931
|
+
* (e.g. `cr-description-check` - first attempt per agent loop blocks
|
|
932
|
+
* as reminder, self-marks via `onFire` so subsequent attempts pass).
|
|
933
|
+
* Anything written via `ctx.appendEntry` gets auto-tagged with the
|
|
934
|
+
* current `_agentLoopIndex` so a follow-up `when.happened:
|
|
935
|
+
* { in: "agent_loop" }` check can detect it.
|
|
936
|
+
*
|
|
937
|
+
* Timing guarantees:
|
|
938
|
+
* - Runs after `pattern` / `requires` / `unless` / `when` have all
|
|
939
|
+
* evaluated favourably. If `when.cwd` or any other predicate
|
|
940
|
+
* fails, the rule doesn't fire and `onFire` doesn't run.
|
|
941
|
+
* - Runs for rules that will actually BLOCK. Rules suppressed by an
|
|
942
|
+
* inline override comment do NOT trigger `onFire` - the agent
|
|
943
|
+
* overrode the rule, so its side effects are bypassed too.
|
|
944
|
+
* - Fail-closed rules (noOverride omitted or true) ignore override
|
|
945
|
+
* comments entirely, so `onFire` runs on every fire even when
|
|
946
|
+
* the agent wrote an override comment the engine rejected.
|
|
947
|
+
*
|
|
948
|
+
* Error handling: `onFire` is a best-effort side effect. If it
|
|
949
|
+
* throws (sync) or its returned promise rejects, the engine logs
|
|
950
|
+
* the error with `console.warn` and proceeds to return the block
|
|
951
|
+
* verdict. The block is not affected by an `onFire` failure - the
|
|
952
|
+
* block decision already passed every predicate, and a broken
|
|
953
|
+
* self-mark must not invalidate it. Mirrors the observer
|
|
954
|
+
* dispatcher's per-observer isolation.
|
|
955
|
+
*
|
|
956
|
+
* Async OK: the evaluator awaits.
|
|
957
|
+
*/
|
|
958
|
+
onFire?: (ctx: PredicateContext) => void | Promise<void>;
|
|
959
|
+
}
|
|
960
|
+
/**
|
|
961
|
+
* Bash rule: gates pi's `bash` tool.
|
|
962
|
+
*
|
|
963
|
+
* `field` is constrained to `"command"` - the evaluator always runs
|
|
964
|
+
* bash rules against the extracted command string per ref (see
|
|
965
|
+
* `evaluator.ts` bash branch). There is no useful "test a bash rule
|
|
966
|
+
* against a path" mode: bash has no path. `field: "path"` /
|
|
967
|
+
* `field: "content"` on a bash rule silently misbehaved in the
|
|
968
|
+
* previous (non-discriminated) schema; the union here makes the
|
|
969
|
+
* mistake a compile error.
|
|
970
|
+
*
|
|
971
|
+
* Inside a rule's predicates / `onFire`, the context exposes the
|
|
972
|
+
* extracted command plus `args` (quote-aware `Word[]`) and
|
|
973
|
+
* `basename` - those are populated per-ref by the evaluator, not by
|
|
974
|
+
* the rule author.
|
|
975
|
+
*/
|
|
976
|
+
export interface BashRule<ObsName extends string = string, Writes extends string = string> extends BaseRule<ObsName, Writes> {
|
|
977
|
+
tool: "bash";
|
|
978
|
+
field: "command";
|
|
979
|
+
}
|
|
980
|
+
/**
|
|
981
|
+
* Write rule: gates pi's `write` tool (whole-file writes).
|
|
982
|
+
*
|
|
983
|
+
* `field` picks the input slot the {@link pattern} tests against:
|
|
984
|
+
* - `"path"` - the target path (regex-gate paths a file may be
|
|
985
|
+
* written to).
|
|
986
|
+
* - `"content"` - the full file contents the agent is writing.
|
|
987
|
+
*/
|
|
988
|
+
export interface WriteRule<ObsName extends string = string, Writes extends string = string> extends BaseRule<ObsName, Writes> {
|
|
989
|
+
tool: "write";
|
|
990
|
+
field: "path" | "content";
|
|
991
|
+
}
|
|
992
|
+
/**
|
|
993
|
+
* Edit rule: gates pi's `edit` tool (targeted oldText/newText patches).
|
|
994
|
+
*
|
|
995
|
+
* `field` picks the input slot the {@link pattern} tests against:
|
|
996
|
+
* - `"path"` - the target path.
|
|
997
|
+
* - `"content"` - the concatenated `newText` of every edit in the
|
|
998
|
+
* tool call (evaluator joins with `\n`). This mirrors
|
|
999
|
+
* `write.content` so authors can use one rule class
|
|
1000
|
+
* for both file surfaces.
|
|
1001
|
+
*/
|
|
1002
|
+
export interface EditRule<ObsName extends string = string, Writes extends string = string> extends BaseRule<ObsName, Writes> {
|
|
1003
|
+
tool: "edit";
|
|
1004
|
+
field: "path" | "content";
|
|
1005
|
+
}
|
|
1006
|
+
/**
|
|
1007
|
+
* A single steering rule - discriminated union over the three
|
|
1008
|
+
* gatable tools. The `tool` discriminant determines which `field`
|
|
1009
|
+
* values are legal: bash rules test against `"command"`, write / edit
|
|
1010
|
+
* rules test against `"path"` or `"content"`. Invalid combinations
|
|
1011
|
+
* (`{ tool: "bash", field: "path" }`, `{ tool: "write", field:
|
|
1012
|
+
* "command" }`, ...) are TS errors.
|
|
1013
|
+
*
|
|
1014
|
+
* Shape refinements vs. v1:
|
|
1015
|
+
* - `pattern` accepts `RegExp` in addition to `string`.
|
|
1016
|
+
* - `requires` / `unless` accept `Pattern | PredicateFn`.
|
|
1017
|
+
* - `when` is a {@link TopLevelWhenClause} — registry-driven
|
|
1018
|
+
* mapped type with one level of `not:` allowed (no nested
|
|
1019
|
+
* `not: not: ...`).
|
|
1020
|
+
* - `observer` references an {@link Observer} by name (string) or
|
|
1021
|
+
* inline definition.
|
|
1022
|
+
* - `Rule` is a discriminated union by `tool`.
|
|
1023
|
+
*
|
|
1024
|
+
* See ADR "Design → Rule schema".
|
|
1025
|
+
*/
|
|
1026
|
+
export type Rule<ObsName extends string = string, Writes extends string = string> = BashRule<ObsName, Writes> | WriteRule<ObsName, Writes> | EditRule<ObsName, Writes>;
|
|
1027
|
+
/**
|
|
1028
|
+
* Filter applied to `tool_result` events before {@link Observer.onResult}
|
|
1029
|
+
* runs. Omitted: the observer fires on every result the engine sees.
|
|
1030
|
+
*
|
|
1031
|
+
* See ADR "Design → Observer schema".
|
|
1032
|
+
*/
|
|
1033
|
+
export interface ObserverWatch {
|
|
1034
|
+
/**
|
|
1035
|
+
* Only fire on results from this tool. Use to narrow observers that
|
|
1036
|
+
* only care about a specific tool surface (e.g. `read` results).
|
|
1037
|
+
*/
|
|
1038
|
+
toolName?: "bash" | "read" | "write" | "edit" | (string & {});
|
|
1039
|
+
/**
|
|
1040
|
+
* Per-field regex constraints on the tool INPUT. Observer fires only
|
|
1041
|
+
* when every listed field matches. Keys are tool-input field names
|
|
1042
|
+
* (e.g. `path` for `read`, `command` for `bash`).
|
|
1043
|
+
*/
|
|
1044
|
+
inputMatches?: Record<string, Pattern>;
|
|
1045
|
+
/**
|
|
1046
|
+
* Constrain by tool exit-code / success-failure classification.
|
|
1047
|
+
* - `"success"` / `"failure"` - string classification
|
|
1048
|
+
* - `number` - exact exit code match (bash)
|
|
1049
|
+
* - `"any"` - explicit no-filter
|
|
1050
|
+
*/
|
|
1051
|
+
exitCode?: number | "success" | "failure" | "any";
|
|
1052
|
+
}
|
|
1053
|
+
/**
|
|
1054
|
+
* Context passed to an observer's {@link Observer.onResult} callback.
|
|
1055
|
+
*
|
|
1056
|
+
* See ADR "Design → Observer schema".
|
|
1057
|
+
*/
|
|
1058
|
+
export interface ObserverContext {
|
|
1059
|
+
/** Session cwd at the time the tool_result arrived. */
|
|
1060
|
+
cwd: string;
|
|
1061
|
+
/**
|
|
1062
|
+
* Monotonically-increasing agent-loop counter maintained by the
|
|
1063
|
+
* engine. Bumped on each `agent_start` pi event (one agent loop =
|
|
1064
|
+
* one user prompt + its tool calls). Observers writing session
|
|
1065
|
+
* entries get this tag auto-injected into the payload so rules
|
|
1066
|
+
* using `when.happened` with `in: "agent_loop"` can filter by it.
|
|
1067
|
+
*/
|
|
1068
|
+
agentLoopIndex: number;
|
|
1069
|
+
/**
|
|
1070
|
+
* Append a typed entry into pi's session JSONL. Observers
|
|
1071
|
+
* typically use this to record "the agent did X in turn N" so
|
|
1072
|
+
* later predicates can gate on it via
|
|
1073
|
+
* {@link PredicateContext.findEntries}.
|
|
1074
|
+
*/
|
|
1075
|
+
appendEntry: <T>(customType: string, data?: T) => void;
|
|
1076
|
+
/**
|
|
1077
|
+
* Read all prior typed entries of the given custom type from pi's
|
|
1078
|
+
* session JSONL. Handy for observers that need to coalesce state
|
|
1079
|
+
* across turns (e.g. "has the agent read the CR description yet?").
|
|
1080
|
+
*/
|
|
1081
|
+
findEntries: <T>(customType: string) => Array<{
|
|
1082
|
+
data: T;
|
|
1083
|
+
timestamp: number;
|
|
1084
|
+
}>;
|
|
1085
|
+
}
|
|
1086
|
+
/**
|
|
1087
|
+
* A reactive hook: runs on `tool_result` events, typically to record
|
|
1088
|
+
* per-turn state for later predicates to consult.
|
|
1089
|
+
*
|
|
1090
|
+
* Observers are named + deduped (first-registered wins; later
|
|
1091
|
+
* declarations log a WARN). A rule may reference an observer by name
|
|
1092
|
+
* via {@link Rule.observer}, letting plugins ship reusable observers
|
|
1093
|
+
* and multiple rules share a single entry-producing observer.
|
|
1094
|
+
*
|
|
1095
|
+
* See ADR "Design → Observer schema" and "Precedence: first-wins
|
|
1096
|
+
* everywhere".
|
|
1097
|
+
*/
|
|
1098
|
+
export interface Observer {
|
|
1099
|
+
/**
|
|
1100
|
+
* Unique name. Used for dedup across plugins + inline observers.
|
|
1101
|
+
* Referenced from {@link Rule.observer} as a string.
|
|
1102
|
+
*/
|
|
1103
|
+
name: string;
|
|
1104
|
+
/**
|
|
1105
|
+
* Session-entry custom types this observer's {@link onResult} may
|
|
1106
|
+
* write.
|
|
1107
|
+
*
|
|
1108
|
+
* **Compile-time effect (via {@link defineConfig}):** the union of
|
|
1109
|
+
* all `writes` literals declared across plugin rules, plugin
|
|
1110
|
+
* observers, user rules, and user observers constrains the `event`
|
|
1111
|
+
* field of every {@link BuiltInWhenLeavesOuter.happened} inside the same config.
|
|
1112
|
+
* Declaring a write here makes it referenceable from
|
|
1113
|
+
* `when.happened.event` anywhere in that config; omitting it leaves
|
|
1114
|
+
* the string out of the union and downstream references to it are
|
|
1115
|
+
* rejected as typos.
|
|
1116
|
+
*
|
|
1117
|
+
* **Authoring pattern.** See {@link Rule.writes} for the full
|
|
1118
|
+
* footgun note - TL;DR: use `as const satisfies Observer` on
|
|
1119
|
+
* reusable observer constants, or declare them inline inside
|
|
1120
|
+
* `defineConfig({ observers: [...] })`. Bare `: Observer` annotations
|
|
1121
|
+
* widen `writes` to `readonly string[]` and collapse `AllWrites` to
|
|
1122
|
+
* `never`, rejecting every `when.happened.event` reference.
|
|
1123
|
+
*
|
|
1124
|
+
* **Runtime effect:** none. `writes` is purely documentation +
|
|
1125
|
+
* type-level plumbing - the engine does NOT verify that `onResult`
|
|
1126
|
+
* only calls `ctx.appendEntry` with declared types.
|
|
1127
|
+
*
|
|
1128
|
+
* **Opt-out:** authors who build their config via
|
|
1129
|
+
* `satisfies SteeringConfig` instead of `defineConfig` lose the
|
|
1130
|
+
* compile-time check. `defineConfig` is the entry point that
|
|
1131
|
+
* enforces.
|
|
1132
|
+
*/
|
|
1133
|
+
writes?: readonly string[];
|
|
1134
|
+
/**
|
|
1135
|
+
* Filter narrowing which tool_result events trigger this observer.
|
|
1136
|
+
* Omitted: every tool_result fires onResult.
|
|
1137
|
+
*/
|
|
1138
|
+
watch?: ObserverWatch;
|
|
1139
|
+
/**
|
|
1140
|
+
* Called on every matching tool_result event. Typically writes an
|
|
1141
|
+
* entry via `ctx.appendEntry(customType, data)`; occasionally
|
|
1142
|
+
* performs side effects (logging). Must be idempotent - the same
|
|
1143
|
+
* event MAY fire the observer more than once across pi's
|
|
1144
|
+
* lifecycle (e.g. session restart mid-turn).
|
|
1145
|
+
*/
|
|
1146
|
+
onResult: (event: ToolResultEvent, ctx: ObserverContext) => void | Promise<void>;
|
|
1147
|
+
}
|
|
1148
|
+
/**
|
|
1149
|
+
* Shape of a tool_result event as observed by an {@link Observer}.
|
|
1150
|
+
*
|
|
1151
|
+
* Intentionally minimal: the fields the schema commits to are the
|
|
1152
|
+
* ones every tool_result carries. Tool-specific `input` / `output`
|
|
1153
|
+
* fields are `unknown` here - observer authors cast to the known
|
|
1154
|
+
* shape for the tool they're watching.
|
|
1155
|
+
*/
|
|
1156
|
+
export interface ToolResultEvent {
|
|
1157
|
+
/** Tool name the result pertains to (e.g. `"bash"`, `"read"`). */
|
|
1158
|
+
toolName: string;
|
|
1159
|
+
/** Tool input as originally passed to the tool. Shape varies by tool. */
|
|
1160
|
+
input: unknown;
|
|
1161
|
+
/** Tool output / result payload. Shape varies by tool. */
|
|
1162
|
+
output: unknown;
|
|
1163
|
+
/** Exit code (bash) or undefined for non-command tools. */
|
|
1164
|
+
exitCode?: number;
|
|
1165
|
+
}
|
|
1166
|
+
/**
|
|
1167
|
+
* Tool input signature reduced to the fields a predicate may read.
|
|
1168
|
+
*
|
|
1169
|
+
* Predicates are tool-agnostic (the same predicate can gate bash, write,
|
|
1170
|
+
* or edit rules). `tool` tells the predicate which discriminator applies;
|
|
1171
|
+
* the evaluator populates whichever fields belong to that tool.
|
|
1172
|
+
*
|
|
1173
|
+
* Bash note (per ADR §9): `command`, `basename`, and `args` are
|
|
1174
|
+
* populated PER extracted command ref - a bash invocation of
|
|
1175
|
+
* `git push --force && ls` runs the predicate once per ref, with
|
|
1176
|
+
* `command: "git push --force"` (flattened for pattern matching),
|
|
1177
|
+
* `basename: "git"`, and `args: [<Word>, <Word>]` (suffix `Word[]`
|
|
1178
|
+
* with quote-aware `.value`). `rawCommand` and full AST node access
|
|
1179
|
+
* are deliberately NOT exposed - the wrapper context would be wrong
|
|
1180
|
+
* for inner refs, and AST walking belongs in plugin code that imports
|
|
1181
|
+
* unbash-walker directly.
|
|
1182
|
+
*/
|
|
1183
|
+
export interface PredicateToolInput {
|
|
1184
|
+
tool: "bash" | "write" | "edit";
|
|
1185
|
+
/** bash: flattened `basename + args` string, per extracted ref. */
|
|
1186
|
+
command?: string;
|
|
1187
|
+
/**
|
|
1188
|
+
* bash: extracted ref basename (e.g. `"git"` for `/usr/bin/git`).
|
|
1189
|
+
* Sugar over `command.split(/\s+/)[0]` that handles path stripping
|
|
1190
|
+
* correctly. Undefined for non-bash tools.
|
|
1191
|
+
*/
|
|
1192
|
+
basename?: string;
|
|
1193
|
+
/**
|
|
1194
|
+
* bash: suffix `Word[]` for the extracted ref - quote-aware
|
|
1195
|
+
* structured access with `.value` giving the lexical value and
|
|
1196
|
+
* `.text` the raw source. Prefer this over splitting `command`
|
|
1197
|
+
* when the predicate needs to preserve quoting (e.g. reading a
|
|
1198
|
+
* `-m "conventional: subject"` message without munging spaces).
|
|
1199
|
+
*
|
|
1200
|
+
* Sourced from `CommandRef.node.suffix` via unbash-walker; the
|
|
1201
|
+
* walker already parses into Word[] so we expose it directly.
|
|
1202
|
+
* Undefined for non-bash tools.
|
|
1203
|
+
*/
|
|
1204
|
+
args?: readonly Word[];
|
|
1205
|
+
/**
|
|
1206
|
+
* bash: shell env-assignment prefix for the extracted ref -
|
|
1207
|
+
* `AWS_PROFILE=dev aws s3 ls` exposes `[W("AWS_PROFILE=dev")]`
|
|
1208
|
+
* here (with `args` still `[W("s3"), W("ls")]`). Multiple
|
|
1209
|
+
* assignments come through in source order. Enables plugins to
|
|
1210
|
+
* inspect shell env vars via structured access instead of
|
|
1211
|
+
* regex-on-raw-command.
|
|
1212
|
+
*
|
|
1213
|
+
* Sourced from `CommandRef.node.prefix` via unbash-walker. Each
|
|
1214
|
+
* prefix element is projected into a `Word` whose `.text` preserves
|
|
1215
|
+
* the full `KEY=VALUE` source token (with quoting, if any);
|
|
1216
|
+
* consumers split on `=` to separate key from value. Dynamic
|
|
1217
|
+
* values like `A=$VAR` come through as-is - the token syntax is
|
|
1218
|
+
* visible in `.text`, so callers can detect the expansion
|
|
1219
|
+
* themselves.
|
|
1220
|
+
*
|
|
1221
|
+
* Always an empty array for `write` / `edit` tools (shell env
|
|
1222
|
+
* assignments don't apply to file-surface tools); shaped as
|
|
1223
|
+
* `[]` rather than `undefined` so plugin authors can treat the
|
|
1224
|
+
* field uniformly.
|
|
1225
|
+
*/
|
|
1226
|
+
envAssignments?: readonly Word[];
|
|
1227
|
+
/** write / edit: the target path. */
|
|
1228
|
+
path?: string;
|
|
1229
|
+
/** write: the file content being written. */
|
|
1230
|
+
content?: string;
|
|
1231
|
+
/** edit: the replacement edits. Shape preserved from pi's edit tool. */
|
|
1232
|
+
edits?: ReadonlyArray<{
|
|
1233
|
+
oldText: string;
|
|
1234
|
+
newText: string;
|
|
1235
|
+
}>;
|
|
1236
|
+
}
|
|
1237
|
+
/**
|
|
1238
|
+
* Options forwarded to {@link PredicateContext.exec} - narrow surface
|
|
1239
|
+
* over child_process, scoped to the handful of knobs predicates need.
|
|
1240
|
+
*/
|
|
1241
|
+
export interface ExecOpts {
|
|
1242
|
+
/** Working directory. Defaults to the session cwd. */
|
|
1243
|
+
cwd?: string;
|
|
1244
|
+
/** Max runtime in ms. Predicates should cap this. */
|
|
1245
|
+
timeoutMs?: number;
|
|
1246
|
+
}
|
|
1247
|
+
/**
|
|
1248
|
+
* Return value of {@link PredicateContext.exec}.
|
|
1249
|
+
*/
|
|
1250
|
+
export interface ExecResult {
|
|
1251
|
+
stdout: string;
|
|
1252
|
+
stderr: string;
|
|
1253
|
+
exitCode: number;
|
|
1254
|
+
}
|
|
1255
|
+
/**
|
|
1256
|
+
* Shape of the walker-state snapshot the evaluator populates on
|
|
1257
|
+
* {@link PredicateContext.walkerState} for bash rules. Consumed by
|
|
1258
|
+
* the built-in `when.cwd` / `when.happened` predicates and by
|
|
1259
|
+
* plugin-authored predicates that read per-ref tracker state.
|
|
1260
|
+
*
|
|
1261
|
+
* All fields are read-only. The evaluator assembles a fresh object
|
|
1262
|
+
* per extracted command ref - mutation has no effect on subsequent
|
|
1263
|
+
* refs or on any persisted state.
|
|
1264
|
+
*
|
|
1265
|
+
* The type is open-ended (`readonly [key: string]: unknown`) because
|
|
1266
|
+
* plugins register new trackers at config-build time; the schema
|
|
1267
|
+
* can't commit to the complete key set. Plugin authors documenting
|
|
1268
|
+
* their own predicates should narrow via their tracker's known
|
|
1269
|
+
* value type (e.g. the git plugin's branch predicate reads
|
|
1270
|
+
* `ctx.walkerState.branch` as a string | "unknown" sentinel).
|
|
1271
|
+
*
|
|
1272
|
+
* For `write` / `edit` rules there's no walker invocation and
|
|
1273
|
+
* {@link PredicateContext.walkerState} is `undefined`. Bash rules
|
|
1274
|
+
* always see at minimum `{ cwd, env, events }`.
|
|
1275
|
+
*/
|
|
1276
|
+
export interface WhenWalkerState {
|
|
1277
|
+
/**
|
|
1278
|
+
* Effective cwd at this ref, per the walker's `cwdTracker`. For
|
|
1279
|
+
* dynamic cd targets the walker couldn't resolve statically
|
|
1280
|
+
* (unknown `$VAR`, command substitution, arithmetic), this is the
|
|
1281
|
+
* literal string `"unknown"` - the cwdTracker's sentinel. The
|
|
1282
|
+
* built-in `when.cwd` predicate applies its `onUnknown: 'allow' |
|
|
1283
|
+
* 'block'` policy on that sentinel (default `'block'`, fail-
|
|
1284
|
+
* closed). Plugin predicates reading this field directly should
|
|
1285
|
+
* check for the sentinel before pattern-matching, or use the
|
|
1286
|
+
* sugar form `when.cwd: { pattern, onUnknown }` via the engine.
|
|
1287
|
+
*/
|
|
1288
|
+
readonly cwd: string;
|
|
1289
|
+
/**
|
|
1290
|
+
* Env map at this ref, per the walker's `envTracker`. Carries
|
|
1291
|
+
* statically-resolved bare assignments (`FOO=bar`), `export`
|
|
1292
|
+
* writes, and `unset` deletions from the current scope, seeded
|
|
1293
|
+
* from `process.env.{HOME, USER, PWD}` at tracker initialization.
|
|
1294
|
+
*
|
|
1295
|
+
* Plugin predicates consume this to expand `$VAR` / `~` in
|
|
1296
|
+
* user-supplied patterns, or to implement a `when.envVar`-style
|
|
1297
|
+
* predicate. Read via `ctx.walkerState.env.get("NAME")`. Returns
|
|
1298
|
+
* `undefined` for any name the walker hasn't seen - callers apply
|
|
1299
|
+
* their own fallback (or route through the `resolveWord` helper
|
|
1300
|
+
* re-exported from the package root for word-level resolution).
|
|
1301
|
+
*/
|
|
1302
|
+
readonly env: EnvState;
|
|
1303
|
+
/**
|
|
1304
|
+
* Additional tracker-registered fields (e.g. the git plugin's
|
|
1305
|
+
* `branch`) and reserved keys (`events`). Indexed loosely so
|
|
1306
|
+
* plugins adding new trackers don't need a schema amendment.
|
|
1307
|
+
*/
|
|
1308
|
+
readonly [key: string]: unknown;
|
|
1309
|
+
}
|
|
1310
|
+
/**
|
|
1311
|
+
* Context passed to a predicate (either {@link PredicateFn} or a
|
|
1312
|
+
* plugin's {@link PredicateHandler}).
|
|
1313
|
+
*
|
|
1314
|
+
* Rationale per ADR "Design → Predicate context":
|
|
1315
|
+
* - `cwd`, `tool`, `input` - what the agent is about to do.
|
|
1316
|
+
* - `agentLoopIndex` - engine-maintained counter bumped on each
|
|
1317
|
+
* pi `agent_start` event (one agent loop = one user prompt + its
|
|
1318
|
+
* tool calls). Rules gate "since the user's last message" state
|
|
1319
|
+
* by comparing entries' auto-tagged `_agentLoopIndex` against
|
|
1320
|
+
* `ctx.agentLoopIndex`, which is what `when.happened` with
|
|
1321
|
+
* `in: "agent_loop"` does internally.
|
|
1322
|
+
* - `exec` - shell escape hatch. The evaluator memoizes results per
|
|
1323
|
+
* `(cmd, args, cwd)` within a single tool_call; no cross-call cache.
|
|
1324
|
+
* This schema commits to the TYPE only - memoization is the
|
|
1325
|
+
* evaluator's concern (Phase 3).
|
|
1326
|
+
* - `appendEntry` / `findEntries` - pi's session JSONL mirror of
|
|
1327
|
+
* what observers write. Predicates consult prior entries to
|
|
1328
|
+
* implement turn-state checks.
|
|
1329
|
+
*/
|
|
1330
|
+
export interface PredicateContext {
|
|
1331
|
+
/** Session cwd (or, for bash rules, the effective cwd of the command). */
|
|
1332
|
+
cwd: string;
|
|
1333
|
+
/** Which pi tool is being gated. */
|
|
1334
|
+
tool: "bash" | "write" | "edit";
|
|
1335
|
+
/** Tool input - evaluator populates whichever fields apply to `tool`. */
|
|
1336
|
+
input: PredicateToolInput;
|
|
1337
|
+
/**
|
|
1338
|
+
* Engine-maintained agent-loop counter (bumped on each pi
|
|
1339
|
+
* `agent_start` event). See {@link ObserverContext.agentLoopIndex}.
|
|
1340
|
+
*/
|
|
1341
|
+
agentLoopIndex: number;
|
|
1342
|
+
/**
|
|
1343
|
+
* Run a command and return its result. Memoized by the evaluator
|
|
1344
|
+
* per `(cmd, args, cwd)` within a single tool_call evaluation.
|
|
1345
|
+
*
|
|
1346
|
+
* Stability guarantee: across rules evaluated for the SAME
|
|
1347
|
+
* tool_call, identical `(cmd, args, cwd)` tuples return the same
|
|
1348
|
+
* ExecResult without re-executing. Across tool_calls, no cache -
|
|
1349
|
+
* the world can change between turns.
|
|
1350
|
+
*/
|
|
1351
|
+
exec: (cmd: string, args: string[], opts?: ExecOpts) => Promise<ExecResult>;
|
|
1352
|
+
/**
|
|
1353
|
+
* Append a typed entry into pi's session JSONL. Parallels
|
|
1354
|
+
* {@link ObserverContext.appendEntry} so predicates can record
|
|
1355
|
+
* decisions (though typically writing is an observer's job).
|
|
1356
|
+
*/
|
|
1357
|
+
appendEntry: <T>(customType: string, data?: T) => void;
|
|
1358
|
+
/**
|
|
1359
|
+
* Read all prior typed entries of the given custom type. Used for
|
|
1360
|
+
* turn-state predicates.
|
|
1361
|
+
*/
|
|
1362
|
+
findEntries: <T>(customType: string) => Array<{
|
|
1363
|
+
data: T;
|
|
1364
|
+
timestamp: number;
|
|
1365
|
+
}>;
|
|
1366
|
+
/**
|
|
1367
|
+
* Walker state snapshot for the command being evaluated. Populated
|
|
1368
|
+
* only for bash rules - the walker runs once per tool_call over the
|
|
1369
|
+
* full command and produces a per-ref snapshot of every registered
|
|
1370
|
+
* tracker (`cwd`, `env`, plus plugin-registered dimensions like
|
|
1371
|
+
* `branch`). For `write` / `edit` rules there is no walker, so this
|
|
1372
|
+
* is `undefined`.
|
|
1373
|
+
*
|
|
1374
|
+
* Plugin predicates consult `walkerState[<tracker-name>]` to read
|
|
1375
|
+
* statically-resolved values (branch after `git checkout X`, cwd
|
|
1376
|
+
* after `cd /path`, env after `FOO=bar` / `export FOO=bar`) without
|
|
1377
|
+
* re-running the tracker's work. When the tracker can't resolve
|
|
1378
|
+
* statically the value is the tracker's `unknown` sentinel -
|
|
1379
|
+
* handlers apply their `onUnknown` policy.
|
|
1380
|
+
*
|
|
1381
|
+
* Typed as {@link WhenWalkerState} (open-ended string-indexed) so
|
|
1382
|
+
* the schema commits to the two built-in keys (`cwd`, `env`) plus
|
|
1383
|
+
* the reserved `events` slot while leaving room for plugin
|
|
1384
|
+
* extensions.
|
|
1385
|
+
*
|
|
1386
|
+
* Reserved key `events`: `Record<customType, SyntheticEntry[]>`.
|
|
1387
|
+
* Populated by the walker-level speculative-entry synthesis pass
|
|
1388
|
+
* (see `evaluator-internals/speculative-synthesis.ts`). Carries
|
|
1389
|
+
* per-ref speculative entries representing "events about to happen"
|
|
1390
|
+
* via continuous `&&` chains from observers' `writes:` declarations.
|
|
1391
|
+
* Each entry carries a `{ data, timestamp, speculative: true }`
|
|
1392
|
+
* shape; timestamps are in a reserved range (above any real entry)
|
|
1393
|
+
* monotonic in AST order. The built-in `when.happened` predicate
|
|
1394
|
+
* merges these with real entries via timestamp comparison;
|
|
1395
|
+
* plugin-authored predicates can opt out by filtering
|
|
1396
|
+
* `e.speculative === true`. Trackers cannot claim the `events`
|
|
1397
|
+
* key - plugin registration rejects it as reserved.
|
|
1398
|
+
*/
|
|
1399
|
+
walkerState?: Readonly<WhenWalkerState>;
|
|
1400
|
+
}
|
|
1401
|
+
/**
|
|
1402
|
+
* A plugin - distribution unit for rule packs and extension points.
|
|
1403
|
+
*
|
|
1404
|
+
* Plugins register zero or more of:
|
|
1405
|
+
* - {@link predicates} - new `when.<key>` slots
|
|
1406
|
+
* - {@link rules} - bundled rules users can enable/disable
|
|
1407
|
+
* - {@link observers} - reusable observer definitions
|
|
1408
|
+
* - {@link trackers} - new walker state dimensions
|
|
1409
|
+
* - {@link trackerExtensions} - modifiers for existing trackers
|
|
1410
|
+
*
|
|
1411
|
+
* See ADR "Design → Plugin schema". Plugin loading precedence is
|
|
1412
|
+
* "first-wins" (project-local → user's `plugins` array → built-in
|
|
1413
|
+
* defaults); name collisions on predicates / rules / observers /
|
|
1414
|
+
* tracker-extensions log a WARN and keep the first-registered entry.
|
|
1415
|
+
* Tracker-*name* collisions are a hard error - two plugins claiming the
|
|
1416
|
+
* same state dimension is always a bug.
|
|
1417
|
+
*/
|
|
1418
|
+
export interface Plugin {
|
|
1419
|
+
/** Unique plugin identifier. Used for `disabledPlugins` + warning messages. */
|
|
1420
|
+
name: string;
|
|
1421
|
+
/**
|
|
1422
|
+
* Predicate handlers keyed by the `when.<key>` slot they register.
|
|
1423
|
+
* See {@link PredicateHandler}.
|
|
1424
|
+
*/
|
|
1425
|
+
predicates?: Record<string, AnyPredicateHandler>;
|
|
1426
|
+
/** Rules the plugin suggests. Users can opt out via `disabledRules: [...]`. */
|
|
1427
|
+
rules?: readonly Rule[];
|
|
1428
|
+
/** Observers the plugin ships. Referenced by name from rules. */
|
|
1429
|
+
observers?: readonly Observer[];
|
|
1430
|
+
/**
|
|
1431
|
+
* NEW trackers the plugin introduces. Keys are tracker names (e.g.
|
|
1432
|
+
* `branch`). A name collision between plugins is a hard error.
|
|
1433
|
+
*/
|
|
1434
|
+
trackers?: Record<string, Tracker<unknown>>;
|
|
1435
|
+
/**
|
|
1436
|
+
* Modifiers added to an EXISTING tracker. Outer key is the tracker
|
|
1437
|
+
* name (e.g. `cwd`), inner key is the command basename the modifier
|
|
1438
|
+
* triggers on (e.g. `git` - to register a `--git-dir=...` parser on
|
|
1439
|
+
* top of the built-in cwd tracker).
|
|
1440
|
+
*
|
|
1441
|
+
* The inner value accepts either a single {@link Modifier} or a
|
|
1442
|
+
* readonly array of them, mirroring {@link Tracker.modifiers} on the
|
|
1443
|
+
* walker side. Plugins can register multiple modifiers under one
|
|
1444
|
+
* `(tracker, basename)` pair - e.g. distinct parsers for different
|
|
1445
|
+
* subcommands of the same CLI that all share a basename.
|
|
1446
|
+
*
|
|
1447
|
+
* Collisions on a `(tracker, basename)` pair log a WARN and keep
|
|
1448
|
+
* the first-registered entry.
|
|
1449
|
+
*
|
|
1450
|
+
* Typed as `unknown` at this schema level - concrete plugins
|
|
1451
|
+
* declare their own modifier types tied to the tracker value they
|
|
1452
|
+
* extend.
|
|
1453
|
+
*/
|
|
1454
|
+
trackerExtensions?: Record<string, Record<string, import("@cad0p/unbash-walker").Modifier<unknown> | readonly import("@cad0p/unbash-walker").Modifier<unknown>[]>>;
|
|
1455
|
+
}
|
|
1456
|
+
/**
|
|
1457
|
+
* Top-level v2 config shape. What a user's `.pi/steering.ts` /
|
|
1458
|
+
* `.pi/steering/index.ts` file default-exports (possibly via
|
|
1459
|
+
* {@link defineConfig} or `satisfies SteeringConfig`).
|
|
1460
|
+
*
|
|
1461
|
+
* The loader walks up from the session cwd to `$HOME`, collects every
|
|
1462
|
+
* layer, and merges them into a single effective config with inner
|
|
1463
|
+
* (closer to cwd) layers taking precedence on name collisions.
|
|
1464
|
+
*
|
|
1465
|
+
* The five array-typed fields (`disabledRules`, `disabledPlugins`,
|
|
1466
|
+
* `plugins`, `rules`, `observers`) are all `readonly`. This is load-
|
|
1467
|
+
* bearing: {@link DefineConfigInput} extends `SteeringConfig` and
|
|
1468
|
+
* narrows each array field with a const-generic-aware subtype (e.g.
|
|
1469
|
+
* `disabledRules` becomes `readonly AllRuleNames<P, R>[]`). TypeScript's
|
|
1470
|
+
* interface-extension check runs on assignability, and `readonly T[]`
|
|
1471
|
+
* is not assignable to `T[]` — demoting any of these fields to a
|
|
1472
|
+
* mutable array would break every typed-name override on
|
|
1473
|
+
* `DefineConfigInput`. The compiler will catch the regression at
|
|
1474
|
+
* the `extends` site, but the choice originates here.
|
|
1475
|
+
*
|
|
1476
|
+
* See ADR "Design → File layout and loader behavior" and
|
|
1477
|
+
* "Design → Override default and `onUnknown`".
|
|
1478
|
+
*/
|
|
1479
|
+
export interface SteeringConfig {
|
|
1480
|
+
/**
|
|
1481
|
+
* Default value for {@link Rule.noOverride} when a rule doesn't
|
|
1482
|
+
* specify its own. Defaults to `true` (fail-closed - overrides
|
|
1483
|
+
* must be explicit opt-in per rule).
|
|
1484
|
+
*
|
|
1485
|
+
* Walk-up merge: inner layer wins when specified; missing layer
|
|
1486
|
+
* leaves the running value alone.
|
|
1487
|
+
*
|
|
1488
|
+
* `buildConfig` preserves `undefined` in the merged output so
|
|
1489
|
+
* downstream evaluators can distinguish "user didn't specify" from
|
|
1490
|
+
* "user explicitly chose false". The fail-closed `?? true` coercion
|
|
1491
|
+
* happens at evaluator time.
|
|
1492
|
+
*
|
|
1493
|
+
* See ADR "Design → Override default".
|
|
1494
|
+
*/
|
|
1495
|
+
defaultNoOverride?: boolean;
|
|
1496
|
+
/**
|
|
1497
|
+
* Rules to disable by name. Additive union across layers.
|
|
1498
|
+
*
|
|
1499
|
+
* Past-participle form (`disabledRules`) reads as a predicate on
|
|
1500
|
+
* state - "these are the rules that are disabled." Distinct from
|
|
1501
|
+
* the imperative flag {@link disableDefaults} (action: disable
|
|
1502
|
+
* the default plugins + rules).
|
|
1503
|
+
*
|
|
1504
|
+
* Disabling a rule is by-design behavior, not a configuration
|
|
1505
|
+
* issue — it does NOT contribute to the diagnostic stream. See
|
|
1506
|
+
* {@link SteeringDiagnosticKind} for the by-design-vs-issue carveout.
|
|
1507
|
+
*
|
|
1508
|
+
* **Navigation note:** these are string literals projected from
|
|
1509
|
+
* `DEFAULT_RULES` (engine defaults), `plugin.rules[*].name`, and
|
|
1510
|
+
* inline `rules[*].name`. Ctrl+Click on a literal jumps to the
|
|
1511
|
+
* `AllRuleNames` union, NOT the rule's source — TypeScript-language
|
|
1512
|
+
* limitation on string-literal union members. To inspect a shipped
|
|
1513
|
+
* default's `reason` / `pattern`, import `DEFAULT_RULES` directly:
|
|
1514
|
+
*
|
|
1515
|
+
* ```ts
|
|
1516
|
+
* import { DEFAULT_RULES } from "@cad0p/pi-steering";
|
|
1517
|
+
* // hover DEFAULT_RULES[0] to see the rule body
|
|
1518
|
+
* ```
|
|
1519
|
+
*/
|
|
1520
|
+
disabledRules?: readonly string[];
|
|
1521
|
+
/**
|
|
1522
|
+
* Plugins to disable by name. Additive union across layers.
|
|
1523
|
+
* A disabled plugin contributes NOTHING - no rules, no observers,
|
|
1524
|
+
* no predicates, no trackers.
|
|
1525
|
+
*
|
|
1526
|
+
* Disabling a plugin is by-design behavior, not a configuration
|
|
1527
|
+
* issue — it does NOT contribute to the diagnostic stream. See
|
|
1528
|
+
* {@link SteeringDiagnosticKind} for the by-design-vs-issue carveout.
|
|
1529
|
+
*
|
|
1530
|
+
* **Navigation note:** same TypeScript-language limitation as
|
|
1531
|
+
* {@link disabledRules} — Ctrl+Click on a string literal jumps to
|
|
1532
|
+
* the `AllPluginNames` union, not the plugin's source. To inspect a
|
|
1533
|
+
* shipped default plugin, import `DEFAULT_PLUGINS` directly:
|
|
1534
|
+
*
|
|
1535
|
+
* ```ts
|
|
1536
|
+
* import { DEFAULT_PLUGINS } from "@cad0p/pi-steering";
|
|
1537
|
+
* // hover DEFAULT_PLUGINS[0] to see the plugin body
|
|
1538
|
+
* ```
|
|
1539
|
+
*/
|
|
1540
|
+
disabledPlugins?: readonly string[];
|
|
1541
|
+
/**
|
|
1542
|
+
* Skip the package's built-in default plugins + default rules.
|
|
1543
|
+
* Handy for isolated test harnesses or strict minimal configs.
|
|
1544
|
+
*
|
|
1545
|
+
* Kept in imperative form (action flag: "disable the defaults")
|
|
1546
|
+
* to distinguish shape at a glance from the past-participle
|
|
1547
|
+
* {@link disabledRules} / {@link disabledPlugins} lists.
|
|
1548
|
+
*
|
|
1549
|
+
* Walk-up merge: inner layer wins when specified.
|
|
1550
|
+
*/
|
|
1551
|
+
disableDefaults?: boolean;
|
|
1552
|
+
/**
|
|
1553
|
+
* Strict-mode opt-out. When `true` (default), any warning-class
|
|
1554
|
+
* {@link SteeringDiagnostic} produced while loading the config
|
|
1555
|
+
* escalates to a thrown error that disables the bridge for the
|
|
1556
|
+
* session. When explicitly set to `false`, warnings fall through
|
|
1557
|
+
* to `console.warn` and the bridge keeps running with whatever
|
|
1558
|
+
* subset of plugins / rules / observers loaded successfully.
|
|
1559
|
+
*
|
|
1560
|
+
* Error-class diagnostics ALWAYS throw regardless of this flag
|
|
1561
|
+
* (e.g. tracker name collision, reserved name violation) — the
|
|
1562
|
+
* engine cannot operate safely with those issues present.
|
|
1563
|
+
*
|
|
1564
|
+
* Walk-up merge: inner layer wins when specified, identical to
|
|
1565
|
+
* {@link disableDefaults}.
|
|
1566
|
+
*
|
|
1567
|
+
* Prior art: Rollup's `failAfterWarnings`, Maven's `failOnWarning`.
|
|
1568
|
+
*/
|
|
1569
|
+
failOnWarnings?: boolean;
|
|
1570
|
+
/** Plugins to load. Order matters for first-wins name collisions. */
|
|
1571
|
+
plugins?: readonly Plugin[];
|
|
1572
|
+
/** User-authored rules. */
|
|
1573
|
+
rules?: readonly Rule[];
|
|
1574
|
+
/** Inline observers (rules reference by name). */
|
|
1575
|
+
observers?: readonly Observer[];
|
|
1576
|
+
}
|
|
1577
|
+
/**
|
|
1578
|
+
* Discriminator categorizing what kind of issue a diagnostic
|
|
1579
|
+
* describes. Stable across versions so tooling and tests can dispatch
|
|
1580
|
+
* on `kind` without parsing the human-readable {@link
|
|
1581
|
+
* SteeringDiagnostic.message}.
|
|
1582
|
+
*
|
|
1583
|
+
* The set is split between two surfaces:
|
|
1584
|
+
* - LOADER (`layer-form-coexistence`, `layer-import-failed`,
|
|
1585
|
+
* `layer-stray-file`, `plugin-name-collision`,
|
|
1586
|
+
* `rule-name-collision`, `observer-name-collision`,
|
|
1587
|
+
* `tracker-name-collision`) — produced while walking up the
|
|
1588
|
+
* filesystem, importing per-layer config files, and merging
|
|
1589
|
+
* layers into a single effective config. The collision kinds in
|
|
1590
|
+
* this group flag duplicates surfaced during layer merge — most
|
|
1591
|
+
* are user-authored (`plugin-name-collision`, `rule-name-collision`,
|
|
1592
|
+
* `observer-name-collision`), but `tracker-name-collision` flags
|
|
1593
|
+
* duplicate plugin-shipped trackers when those plugins surface
|
|
1594
|
+
* together via the merge.
|
|
1595
|
+
* - PLUGIN-MERGER (`predicate-collision`, `observer-collision`,
|
|
1596
|
+
* `rule-collision`, `extension-orphan`, `reserved-tracker-name`,
|
|
1597
|
+
* `reserved-predicate-key`, `invalid-name`) — produced while
|
|
1598
|
+
* resolving plugin shapes into the runtime registry. The
|
|
1599
|
+
* collision kinds in this group flag duplicates among
|
|
1600
|
+
* plugin-author-shipped declarations across the active plugin
|
|
1601
|
+
* set; `invalid-name` flags a plugin / rule / observer name
|
|
1602
|
+
* containing characters that are disallowed in source-tagged
|
|
1603
|
+
* block reasons.
|
|
1604
|
+
*
|
|
1605
|
+
* Disabling a plugin via `config.disabledPlugins` or a plugin-shipped
|
|
1606
|
+
* rule via `config.disabledRules` is by-design behavior, not a
|
|
1607
|
+
* configuration issue, so neither contributes to the diagnostic
|
|
1608
|
+
* stream. Both surface as `console.info` breadcrumbs from
|
|
1609
|
+
* `resolvePlugins` for plugin authors debugging "why isn't my plugin
|
|
1610
|
+
* firing?" — mirrors the unused-observer drop pattern in
|
|
1611
|
+
* `internal/session-runtime.ts`.
|
|
1612
|
+
*/
|
|
1613
|
+
export type SteeringDiagnosticKind =
|
|
1614
|
+
/**
|
|
1615
|
+
* Both `.pi/steering/index.ts` AND `.pi/steering.ts` exist at the
|
|
1616
|
+
* same directory. The directory form wins; the flat form is
|
|
1617
|
+
* ignored. Almost always a forgotten cleanup; delete the unused
|
|
1618
|
+
* file to silence the diagnostic.
|
|
1619
|
+
*/
|
|
1620
|
+
"layer-form-coexistence"
|
|
1621
|
+
/**
|
|
1622
|
+
* A layer's `.pi/steering/index.ts` (or `.pi/steering.ts`) was
|
|
1623
|
+
* found on disk but its dynamic import threw — typically a syntax
|
|
1624
|
+
* error or a missing default export. The layer is skipped; outer
|
|
1625
|
+
* layers continue to load.
|
|
1626
|
+
*/
|
|
1627
|
+
| "layer-import-failed"
|
|
1628
|
+
/**
|
|
1629
|
+
* A non-`.ts` file lives under `<dir>/.pi/steering/` (e.g.
|
|
1630
|
+
* `rules.json`, `rules.mjs`). Helpers ending in `.ts` are allowed;
|
|
1631
|
+
* other extensions are flagged so the user can rename or delete
|
|
1632
|
+
* the stray file.
|
|
1633
|
+
*/
|
|
1634
|
+
| "layer-stray-file"
|
|
1635
|
+
/**
|
|
1636
|
+
* Two layers register a plugin with the same `name`. The inner
|
|
1637
|
+
* (closer to cwd) layer wins; the outer layer's plugin is dropped.
|
|
1638
|
+
*/
|
|
1639
|
+
| "plugin-name-collision"
|
|
1640
|
+
/**
|
|
1641
|
+
* A single layer declares two rules under the same `name`. The
|
|
1642
|
+
* first-declared rule survives; subsequent duplicates are dropped.
|
|
1643
|
+
* Cross-layer rule shadowing is intentional and not flagged.
|
|
1644
|
+
*/
|
|
1645
|
+
| "rule-name-collision"
|
|
1646
|
+
/**
|
|
1647
|
+
* A single layer declares two observers under the same `name`.
|
|
1648
|
+
* The first-declared observer survives; subsequent duplicates are
|
|
1649
|
+
* dropped. Cross-layer observer shadowing is intentional and not
|
|
1650
|
+
* flagged.
|
|
1651
|
+
*/
|
|
1652
|
+
| "observer-name-collision"
|
|
1653
|
+
/**
|
|
1654
|
+
* Two plugins both register a tracker under the same name. Always
|
|
1655
|
+
* an error — two plugins claiming the same state dimension is a
|
|
1656
|
+
* bug, not a soft override. Rename one tracker or disable one
|
|
1657
|
+
* plugin.
|
|
1658
|
+
*/
|
|
1659
|
+
| "tracker-name-collision"
|
|
1660
|
+
/**
|
|
1661
|
+
* Two plugins both register a predicate handler under the same
|
|
1662
|
+
* `when.<key>`. The first-registered handler wins; the later
|
|
1663
|
+
* plugin's handler is dropped.
|
|
1664
|
+
*/
|
|
1665
|
+
| "predicate-collision"
|
|
1666
|
+
/**
|
|
1667
|
+
* Two plugins both register an observer with the same `name`.
|
|
1668
|
+
* The first-registered observer wins; the later plugin's observer
|
|
1669
|
+
* is dropped. Distinct from `observer-name-collision` which
|
|
1670
|
+
* applies to within-layer duplicates in user-authored config.
|
|
1671
|
+
*/
|
|
1672
|
+
| "observer-collision"
|
|
1673
|
+
/**
|
|
1674
|
+
* Two plugins both ship a rule with the same `name`. The
|
|
1675
|
+
* first-registered rule wins; the later plugin's rule is dropped.
|
|
1676
|
+
* Distinct from `rule-name-collision` which applies to
|
|
1677
|
+
* within-layer duplicates in user-authored config.
|
|
1678
|
+
*/
|
|
1679
|
+
| "rule-collision"
|
|
1680
|
+
/**
|
|
1681
|
+
* A plugin's `trackerExtensions` references a tracker name that
|
|
1682
|
+
* no plugin (and no built-in walker tracker) registers. The
|
|
1683
|
+
* extension is ignored.
|
|
1684
|
+
*/
|
|
1685
|
+
| "extension-orphan"
|
|
1686
|
+
/**
|
|
1687
|
+
* A plugin attempts to register a tracker under a reserved name
|
|
1688
|
+
* (e.g. `events`). Always an error — reserved names are owned by
|
|
1689
|
+
* the engine. Rename the tracker.
|
|
1690
|
+
*/
|
|
1691
|
+
| "reserved-tracker-name"
|
|
1692
|
+
/**
|
|
1693
|
+
* A plugin attempts to register a predicate handler under a
|
|
1694
|
+
* reserved key (an operator field like `not` or a modifier key
|
|
1695
|
+
* like `onUnknown`). Always an error — reserved keys collide
|
|
1696
|
+
* with the schema's operator/modifier surface. Rename the
|
|
1697
|
+
* predicate.
|
|
1698
|
+
*/
|
|
1699
|
+
| "reserved-predicate-key"
|
|
1700
|
+
/**
|
|
1701
|
+
* A plugin / rule / observer name contains characters that are
|
|
1702
|
+
* disallowed in the `[steering:<name>@<source>]` block-reason
|
|
1703
|
+
* tag shown to the LLM, in `disabledRules` / `disabledPlugins`
|
|
1704
|
+
* config references, or in override-comment targets. Always an
|
|
1705
|
+
* error — names flow into user-visible strings and a malformed
|
|
1706
|
+
* (or maliciously-crafted) name lets a config author forge
|
|
1707
|
+
* block reasons that deceive the agent. Allowed: letters,
|
|
1708
|
+
* digits, underscores, dashes; must start with a letter or
|
|
1709
|
+
* digit. Rename the offending object in source.
|
|
1710
|
+
*/
|
|
1711
|
+
| "invalid-name";
|
|
1712
|
+
/**
|
|
1713
|
+
* Structured issue surfaced while loading a steering config.
|
|
1714
|
+
*
|
|
1715
|
+
* Diagnostics flow up from the loader and the plugin merger into the
|
|
1716
|
+
* bridge runtime, which decides whether to throw or log per the user's
|
|
1717
|
+
* strict-mode preference. The shape is stable so tests and future
|
|
1718
|
+
* tooling can dispatch on {@link kind} without scanning {@link message}
|
|
1719
|
+
* substrings.
|
|
1720
|
+
*
|
|
1721
|
+
* Channel-ownership split (loader / merger vs. runtime). Diagnostics
|
|
1722
|
+
* captured in this stream are by-design surfaced to the strict-mode
|
|
1723
|
+
* runtime so it can decide whether to throw or pass through to
|
|
1724
|
+
* `console.warn`. The loader (`loader.ts`) does not call
|
|
1725
|
+
* `console.*` directly — the runtime owns the policy decision.
|
|
1726
|
+
* However, by-design info breadcrumbs that are NOT configuration
|
|
1727
|
+
* issues (`disabledPlugins` and `disabledRules` opt-outs from
|
|
1728
|
+
* `resolvePlugins`, dropped-observer notices from
|
|
1729
|
+
* `dropUnusedObservers`) go directly to `console.info` from where
|
|
1730
|
+
* they're produced. They're not in this kind union because they
|
|
1731
|
+
* describe normal behavior the user opted into, not problems that
|
|
1732
|
+
* need actioning.
|
|
1733
|
+
*
|
|
1734
|
+
* Render-format matrix — the same diagnostic surfaces in two
|
|
1735
|
+
* shapes depending on which renderer the runtime picks:
|
|
1736
|
+
*
|
|
1737
|
+
* - Multi-line aggregate (thrown `Error` from `buildSessionRuntime`):
|
|
1738
|
+
* a header line ("N config issues:") followed by a per-line bullet
|
|
1739
|
+
* ` - [type] <path: >?<message>`. One `Error.message`, multi-line.
|
|
1740
|
+
* Used when at least one diagnostic must abort the session.
|
|
1741
|
+
* Produced by `formatAggregatedDiagnostics`.
|
|
1742
|
+
* - Single-line per-diagnostic (`formatSingleLineDiagnostic`):
|
|
1743
|
+
* `[pi-steering] [<severity>] <path: >?<message>` per diagnostic.
|
|
1744
|
+
* Severity tag (`[error]` / `[warning]`) follows the same
|
|
1745
|
+
* bracketed convention as the multi-line aggregate's per-line
|
|
1746
|
+
* bullets. Routed to `console.warn` for legacy fail-soft mode
|
|
1747
|
+
* (`failOnWarnings: false`). Only warnings reach this route in
|
|
1748
|
+
* practice — error-class diagnostics escalate to a thrown error
|
|
1749
|
+
* via the aggregated form before warnings are flushed. Also
|
|
1750
|
+
* routed to stderr for the CLI `pi-steering list` pre-flight
|
|
1751
|
+
* surface (both warnings and errors render here; the bracketed
|
|
1752
|
+
* severity tag distinguishes them). The function itself accepts
|
|
1753
|
+
* both severities; the warnings-only narrowing is a property of
|
|
1754
|
+
* the `console.warn` route's caller, not the formatter.
|
|
1755
|
+
*
|
|
1756
|
+
* The CLI prints diagnostics inline as the loader yields them, rather
|
|
1757
|
+
* than aggregating into a thrown error — the single-line shape
|
|
1758
|
+
* gives `pi-steering list` users immediate per-issue feedback.
|
|
1759
|
+
*/
|
|
1760
|
+
export interface SteeringDiagnostic {
|
|
1761
|
+
/**
|
|
1762
|
+
* Severity of the diagnostic.
|
|
1763
|
+
*
|
|
1764
|
+
* - `"warning"` — informational; safe to ignore in legacy
|
|
1765
|
+
* fail-soft mode.
|
|
1766
|
+
* - `"error"` — pi-steering cannot operate safely with this
|
|
1767
|
+
* issue present (e.g. tracker name collision); always escalates
|
|
1768
|
+
* to a thrown error regardless of the user's strict-mode
|
|
1769
|
+
* preference.
|
|
1770
|
+
*/
|
|
1771
|
+
type: "warning" | "error";
|
|
1772
|
+
/** Discriminator for programmatic dispatch and test assertions. */
|
|
1773
|
+
kind: SteeringDiagnosticKind;
|
|
1774
|
+
/** Agent-facing message; includes context like layer path or names. */
|
|
1775
|
+
message: string;
|
|
1776
|
+
/**
|
|
1777
|
+
* Source path, when applicable. Per kind:
|
|
1778
|
+
* - `layer-import-failed`: the source file the loader couldn't import.
|
|
1779
|
+
* - `layer-stray-file`: the stray file under `.pi/steering/`.
|
|
1780
|
+
* - `layer-form-coexistence`: the directory holding both forms
|
|
1781
|
+
* (`.pi/steering.ts` AND `.pi/steering/index.ts`); the dir is
|
|
1782
|
+
* intentional rather than picking one of the two coexisting files
|
|
1783
|
+
* arbitrarily.
|
|
1784
|
+
* - Within-layer collisions (`rule-name-collision`,
|
|
1785
|
+
* `observer-name-collision`): unset (the diagnostic names the
|
|
1786
|
+
* offending object inside `message`).
|
|
1787
|
+
* - Cross-layer collisions and plugin-shipped diagnostics
|
|
1788
|
+
* (`plugin-name-collision`, `tracker-name-collision`,
|
|
1789
|
+
* `predicate-collision`, `observer-collision`, `rule-collision`,
|
|
1790
|
+
* `extension-orphan`, `reserved-tracker-name`,
|
|
1791
|
+
* `reserved-predicate-key`, `invalid-name`): unset by design.
|
|
1792
|
+
* These diagnostics name the participants (layer paths or
|
|
1793
|
+
* plugin names) inside `message` because there is no single
|
|
1794
|
+
* source path — the collision spans multiple layers or
|
|
1795
|
+
* plugins.
|
|
1796
|
+
*/
|
|
1797
|
+
path?: string;
|
|
1798
|
+
}
|
|
1799
|
+
//# sourceMappingURL=schema.d.ts.map
|