@cad0p/pi-steering 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/LICENSE +29 -0
- package/README.md +920 -0
- package/dist/__test-helpers__.d.ts +95 -0
- package/dist/__test-helpers__.d.ts.map +1 -0
- package/dist/__test-helpers__.js +174 -0
- package/dist/__test-helpers__.js.map +1 -0
- package/dist/bin/pi-steering.d.ts +9 -0
- package/dist/bin/pi-steering.d.ts.map +1 -0
- package/dist/bin/pi-steering.js +506 -0
- package/dist/bin/pi-steering.js.map +1 -0
- package/dist/compat.d.ts +50 -0
- package/dist/compat.d.ts.map +1 -0
- package/dist/compat.js +180 -0
- package/dist/compat.js.map +1 -0
- package/dist/defaults.d.ts +61 -0
- package/dist/defaults.d.ts.map +1 -0
- package/dist/defaults.js +106 -0
- package/dist/defaults.js.map +1 -0
- package/dist/define-config.d.ts +235 -0
- package/dist/define-config.d.ts.map +1 -0
- package/dist/define-config.js +118 -0
- package/dist/define-config.js.map +1 -0
- package/dist/define-predicate.d.ts +49 -0
- package/dist/define-predicate.d.ts.map +1 -0
- package/dist/define-predicate.js +35 -0
- package/dist/define-predicate.js.map +1 -0
- package/dist/evaluator-internals/context.d.ts +186 -0
- package/dist/evaluator-internals/context.d.ts.map +1 -0
- package/dist/evaluator-internals/context.js +219 -0
- package/dist/evaluator-internals/context.js.map +1 -0
- package/dist/evaluator-internals/override.d.ts +19 -0
- package/dist/evaluator-internals/override.d.ts.map +1 -0
- package/dist/evaluator-internals/override.js +79 -0
- package/dist/evaluator-internals/override.js.map +1 -0
- package/dist/evaluator-internals/predicates.d.ts +152 -0
- package/dist/evaluator-internals/predicates.d.ts.map +1 -0
- package/dist/evaluator-internals/predicates.js +849 -0
- package/dist/evaluator-internals/predicates.js.map +1 -0
- package/dist/evaluator-internals/speculative-synthesis.d.ts +102 -0
- package/dist/evaluator-internals/speculative-synthesis.d.ts.map +1 -0
- package/dist/evaluator-internals/speculative-synthesis.js +101 -0
- package/dist/evaluator-internals/speculative-synthesis.js.map +1 -0
- package/dist/evaluator.d.ts +101 -0
- package/dist/evaluator.d.ts.map +1 -0
- package/dist/evaluator.js +714 -0
- package/dist/evaluator.js.map +1 -0
- package/dist/helpers/walker-unknown-cwd-reason.d.ts +68 -0
- package/dist/helpers/walker-unknown-cwd-reason.d.ts.map +1 -0
- package/dist/helpers/walker-unknown-cwd-reason.js +73 -0
- package/dist/helpers/walker-unknown-cwd-reason.js.map +1 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +93 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/drop-unused-observers.d.ts +43 -0
- package/dist/internal/drop-unused-observers.d.ts.map +1 -0
- package/dist/internal/drop-unused-observers.js +50 -0
- package/dist/internal/drop-unused-observers.js.map +1 -0
- package/dist/internal/finalize-plugin-state.d.ts +22 -0
- package/dist/internal/finalize-plugin-state.d.ts.map +1 -0
- package/dist/internal/finalize-plugin-state.js +14 -0
- package/dist/internal/finalize-plugin-state.js.map +1 -0
- package/dist/internal/merge-observers.d.ts +30 -0
- package/dist/internal/merge-observers.d.ts.map +1 -0
- package/dist/internal/merge-observers.js +27 -0
- package/dist/internal/merge-observers.js.map +1 -0
- package/dist/internal/pattern-utils.d.ts +33 -0
- package/dist/internal/pattern-utils.d.ts.map +1 -0
- package/dist/internal/pattern-utils.js +20 -0
- package/dist/internal/pattern-utils.js.map +1 -0
- package/dist/internal/ref-text.d.ts +11 -0
- package/dist/internal/ref-text.d.ts.map +1 -0
- package/dist/internal/ref-text.js +15 -0
- package/dist/internal/ref-text.js.map +1 -0
- package/dist/internal/session-runtime.d.ts +44 -0
- package/dist/internal/session-runtime.d.ts.map +1 -0
- package/dist/internal/session-runtime.js +143 -0
- package/dist/internal/session-runtime.js.map +1 -0
- package/dist/internal/watch-matcher.d.ts +56 -0
- package/dist/internal/watch-matcher.d.ts.map +1 -0
- package/dist/internal/watch-matcher.js +179 -0
- package/dist/internal/watch-matcher.js.map +1 -0
- package/dist/loader.d.ts +97 -0
- package/dist/loader.d.ts.map +1 -0
- package/dist/loader.js +527 -0
- package/dist/loader.js.map +1 -0
- package/dist/observer-dispatcher.d.ts +69 -0
- package/dist/observer-dispatcher.d.ts.map +1 -0
- package/dist/observer-dispatcher.js +184 -0
- package/dist/observer-dispatcher.js.map +1 -0
- package/dist/plugin-merger.d.ts +175 -0
- package/dist/plugin-merger.d.ts.map +1 -0
- package/dist/plugin-merger.js +450 -0
- package/dist/plugin-merger.js.map +1 -0
- package/dist/plugins/git/branch-tracker.d.ts +85 -0
- package/dist/plugins/git/branch-tracker.d.ts.map +1 -0
- package/dist/plugins/git/branch-tracker.js +181 -0
- package/dist/plugins/git/branch-tracker.js.map +1 -0
- package/dist/plugins/git/cwd-extensions.d.ts +10 -0
- package/dist/plugins/git/cwd-extensions.d.ts.map +1 -0
- package/dist/plugins/git/cwd-extensions.js +155 -0
- package/dist/plugins/git/cwd-extensions.js.map +1 -0
- package/dist/plugins/git/git-ops.d.ts +89 -0
- package/dist/plugins/git/git-ops.d.ts.map +1 -0
- package/dist/plugins/git/git-ops.js +107 -0
- package/dist/plugins/git/git-ops.js.map +1 -0
- package/dist/plugins/git/index.d.ts +225 -0
- package/dist/plugins/git/index.d.ts.map +1 -0
- package/dist/plugins/git/index.js +52 -0
- package/dist/plugins/git/index.js.map +1 -0
- package/dist/plugins/git/predicates.d.ts +312 -0
- package/dist/plugins/git/predicates.d.ts.map +1 -0
- package/dist/plugins/git/predicates.js +580 -0
- package/dist/plugins/git/predicates.js.map +1 -0
- package/dist/plugins/git/rules.d.ts +209 -0
- package/dist/plugins/git/rules.d.ts.map +1 -0
- package/dist/plugins/git/rules.js +267 -0
- package/dist/plugins/git/rules.js.map +1 -0
- package/dist/schema.d.ts +1799 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +4 -0
- package/dist/schema.js.map +1 -0
- package/dist/testing/index.d.ts +621 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +747 -0
- package/dist/testing/index.js.map +1 -0
- package/examples/README.md +83 -0
- package/examples/combined-git-discipline/README.md +52 -0
- package/examples/combined-git-discipline/node_modules/.bin/pi-steering +43 -0
- package/examples/combined-git-discipline/package.json +18 -0
- package/examples/combined-git-discipline/steering.json +27 -0
- package/examples/combined-git-discipline/steering.test.ts +42 -0
- package/examples/combined-git-discipline/steering.ts +64 -0
- package/examples/combined-git-discipline/tsconfig.json +7 -0
- package/examples/draft-prs-only/README.md +42 -0
- package/examples/draft-prs-only/node_modules/.bin/pi-steering +43 -0
- package/examples/draft-prs-only/package.json +18 -0
- package/examples/draft-prs-only/steering.json +12 -0
- package/examples/draft-prs-only/steering.test.ts +48 -0
- package/examples/draft-prs-only/steering.ts +36 -0
- package/examples/draft-prs-only/tsconfig.json +7 -0
- package/examples/dynamic-reason-runtime-cwd/README.md +148 -0
- package/examples/dynamic-reason-runtime-cwd/node_modules/.bin/pi-steering +43 -0
- package/examples/dynamic-reason-runtime-cwd/package.json +18 -0
- package/examples/dynamic-reason-runtime-cwd/steering.test.ts +130 -0
- package/examples/dynamic-reason-runtime-cwd/steering.ts +82 -0
- package/examples/dynamic-reason-runtime-cwd/tsconfig.json +7 -0
- package/examples/force-push-strict/README.md +75 -0
- package/examples/force-push-strict/node_modules/.bin/pi-steering +43 -0
- package/examples/force-push-strict/package.json +18 -0
- package/examples/force-push-strict/steering.json +12 -0
- package/examples/force-push-strict/steering.test.ts +51 -0
- package/examples/force-push-strict/steering.ts +54 -0
- package/examples/force-push-strict/tsconfig.json +7 -0
- package/examples/no-amend/README.md +52 -0
- package/examples/no-amend/node_modules/.bin/pi-steering +43 -0
- package/examples/no-amend/package.json +18 -0
- package/examples/no-amend/steering.cwd-scoped.json +12 -0
- package/examples/no-amend/steering.json +11 -0
- package/examples/no-amend/steering.test.ts +49 -0
- package/examples/no-amend/steering.ts +38 -0
- package/examples/no-amend/tsconfig.json +7 -0
- package/examples/work-item-plugin/README.md +103 -0
- package/examples/work-item-plugin/node_modules/.bin/pi-steering +43 -0
- package/examples/work-item-plugin/package.json +19 -0
- package/examples/work-item-plugin/src/index.test.ts +168 -0
- package/examples/work-item-plugin/src/index.ts +122 -0
- package/examples/work-item-plugin/src/observers/npm-test-tracker.test.ts +95 -0
- package/examples/work-item-plugin/src/observers/npm-test-tracker.ts +88 -0
- package/examples/work-item-plugin/src/observers/retest-required-tracker.test.ts +81 -0
- package/examples/work-item-plugin/src/observers/retest-required-tracker.ts +81 -0
- package/examples/work-item-plugin/src/predicates/work-item-format.test.ts +157 -0
- package/examples/work-item-plugin/src/predicates/work-item-format.ts +99 -0
- package/examples/work-item-plugin/src/rules/commit-description-check.test.ts +107 -0
- package/examples/work-item-plugin/src/rules/commit-description-check.ts +83 -0
- package/examples/work-item-plugin/src/rules/commit-requires-work-item.test.ts +79 -0
- package/examples/work-item-plugin/src/rules/commit-requires-work-item.ts +56 -0
- package/examples/work-item-plugin/src/rules/push-requires-tests.test.ts +245 -0
- package/examples/work-item-plugin/src/rules/push-requires-tests.ts +62 -0
- package/examples/work-item-plugin/tsconfig.json +8 -0
- package/package.json +88 -0
- package/skills/steering-authoring/SKILL.md +102 -0
- package/src/INVARIANTS.md +116 -0
- package/src/__test-helpers__.ts +243 -0
- package/src/bin/pi-steering.test.ts +920 -0
- package/src/bin/pi-steering.ts +584 -0
- package/src/compat.test.ts +431 -0
- package/src/compat.ts +282 -0
- package/src/defaults.test.ts +585 -0
- package/src/defaults.ts +148 -0
- package/src/define-config.test.ts +888 -0
- package/src/define-config.ts +375 -0
- package/src/define-predicate.test.ts +70 -0
- package/src/define-predicate.ts +57 -0
- package/src/evaluator-internals/context.ts +290 -0
- package/src/evaluator-internals/override.test.ts +99 -0
- package/src/evaluator-internals/override.ts +83 -0
- package/src/evaluator-internals/predicates.ts +1037 -0
- package/src/evaluator-internals/speculative-synthesis.test.ts +411 -0
- package/src/evaluator-internals/speculative-synthesis.ts +199 -0
- package/src/evaluator.test.ts +6296 -0
- package/src/evaluator.ts +1106 -0
- package/src/factory-time-load.test.ts +630 -0
- package/src/helpers/walker-unknown-cwd-reason.test.ts +73 -0
- package/src/helpers/walker-unknown-cwd-reason.ts +83 -0
- package/src/index.test.ts +836 -0
- package/src/index.ts +229 -0
- package/src/internal/drop-unused-observers.test.ts +141 -0
- package/src/internal/drop-unused-observers.ts +81 -0
- package/src/internal/finalize-plugin-state.ts +43 -0
- package/src/internal/merge-observers.ts +51 -0
- package/src/internal/pattern-utils.test.ts +52 -0
- package/src/internal/pattern-utils.ts +38 -0
- package/src/internal/ref-text.test.ts +54 -0
- package/src/internal/ref-text.ts +17 -0
- package/src/internal/session-runtime.test.ts +629 -0
- package/src/internal/session-runtime.ts +201 -0
- package/src/internal/watch-matcher.test.ts +326 -0
- package/src/internal/watch-matcher.ts +200 -0
- package/src/jsdoc-propagation.test.ts +277 -0
- package/src/loader.test.ts +1098 -0
- package/src/loader.ts +581 -0
- package/src/not-block-onunknown.test.ts +554 -0
- package/src/observer-dispatcher.test.ts +939 -0
- package/src/observer-dispatcher.ts +290 -0
- package/src/per-predicate-typing.test.ts +361 -0
- package/src/plugin-merger.test.ts +790 -0
- package/src/plugin-merger.ts +598 -0
- package/src/plugins/git/README.md +485 -0
- package/src/plugins/git/branch-tracker.test.ts +281 -0
- package/src/plugins/git/branch-tracker.ts +187 -0
- package/src/plugins/git/cwd-extensions.test.ts +230 -0
- package/src/plugins/git/cwd-extensions.ts +160 -0
- package/src/plugins/git/git-ops.test.ts +397 -0
- package/src/plugins/git/git-ops.ts +173 -0
- package/src/plugins/git/index.ts +253 -0
- package/src/plugins/git/integration.test.ts +705 -0
- package/src/plugins/git/predicates.test.ts +1401 -0
- package/src/plugins/git/predicates.ts +693 -0
- package/src/plugins/git/rules.test.ts +1001 -0
- package/src/plugins/git/rules.ts +307 -0
- package/src/schema.test.ts +259 -0
- package/src/schema.ts +1963 -0
- package/src/testing/index.test.ts +1696 -0
- package/src/testing/index.ts +1472 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Part of pi-steering.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Predicate context construction for the v2 evaluator.
|
|
6
|
+
*
|
|
7
|
+
* Two concerns live here because they collaborate tightly:
|
|
8
|
+
*
|
|
9
|
+
* 1. `createExecCache` — memoizes `exec(cmd, args, opts)` by
|
|
10
|
+
* `(cmd, args, cwd)` so every rule evaluated for ONE tool_call
|
|
11
|
+
* sees the same result for the same query, without re-running the
|
|
12
|
+
* underlying child process. A fresh cache is created per
|
|
13
|
+
* tool_call; cross-call results are never shared.
|
|
14
|
+
* 2. `createFindEntries` — wraps pi's `sessionManager.getEntries()`
|
|
15
|
+
* into the {@link PredicateContext.findEntries} shape, filtering
|
|
16
|
+
* to `type: "custom"` entries by `customType` and flattening to
|
|
17
|
+
* `{ data, timestamp }` (timestamps normalized from ISO strings to
|
|
18
|
+
* epoch ms, matching what observers producing entries can rely on).
|
|
19
|
+
*
|
|
20
|
+
* The evaluator itself assembles the final {@link PredicateContext}
|
|
21
|
+
* from these closures plus per-candidate fields (cwd / tool / input /
|
|
22
|
+
* agentLoopIndex) as an object literal — no helper needed once the shape
|
|
23
|
+
* is shared across bash and write/edit code paths.
|
|
24
|
+
*
|
|
25
|
+
* Kept internal (under `evaluator-internals/`) so consumers can swap
|
|
26
|
+
* the evaluator without inheriting its helper surface. The only
|
|
27
|
+
* re-export is through `../evaluator.ts`.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type {
|
|
31
|
+
ExtensionAPI,
|
|
32
|
+
ExtensionContext,
|
|
33
|
+
ExecOptions as PiExecOptions,
|
|
34
|
+
ExecResult as PiExecResult,
|
|
35
|
+
} from "@earendil-works/pi-coding-agent";
|
|
36
|
+
import type { ExecOpts, ExecResult, PredicateContext } from "../schema.ts";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Narrow host surface the evaluator needs from the pi runtime. Lets
|
|
40
|
+
* tests pass a stub without building a full fake `ExtensionAPI`, and
|
|
41
|
+
* keeps the evaluator decoupled from the unrelated parts of pi's API
|
|
42
|
+
* (tool registration, slash commands, OAuth, …).
|
|
43
|
+
*/
|
|
44
|
+
export interface EvaluatorHost {
|
|
45
|
+
/** See {@link ExtensionAPI.exec}. */
|
|
46
|
+
exec: ExtensionAPI["exec"];
|
|
47
|
+
/** See {@link ExtensionAPI.appendEntry}. */
|
|
48
|
+
appendEntry: ExtensionAPI["appendEntry"];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Key used by the per-tool-call exec cache. Null-byte separator is safe
|
|
53
|
+
* because neither a command path nor POSIX argv can legitimately contain
|
|
54
|
+
* a NUL byte; collisions are impossible in practice.
|
|
55
|
+
*/
|
|
56
|
+
function execCacheKey(
|
|
57
|
+
cmd: string,
|
|
58
|
+
args: readonly string[],
|
|
59
|
+
cwd: string,
|
|
60
|
+
): string {
|
|
61
|
+
return `${cmd}\x00${args.join("\x00")}\x00${cwd}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Bridge pi's `ExecResult` (uses `code`) to the schema's `ExecResult`
|
|
66
|
+
* (uses `exitCode`). Dropping `killed` is intentional — predicate
|
|
67
|
+
* authors don't need to distinguish "timed out" from "exited
|
|
68
|
+
* non-zero"; both surface as a non-zero exit for guardrail purposes.
|
|
69
|
+
*/
|
|
70
|
+
function toSchemaExecResult(r: PiExecResult): ExecResult {
|
|
71
|
+
return { stdout: r.stdout, stderr: r.stderr, exitCode: r.code };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Create a tool_call-scoped exec function that memoizes by
|
|
76
|
+
* `(cmd, args, cwd)`. Caches only the PROMISE so concurrent
|
|
77
|
+
* predicate evaluations that hit the same key await the same
|
|
78
|
+
* in-flight child process — not N redundant ones.
|
|
79
|
+
*
|
|
80
|
+
* `opts.cwd` defaults to `sessionCwd` (mirroring how predicates see
|
|
81
|
+
* their "current cwd" via {@link PredicateContext.cwd}). `timeoutMs`
|
|
82
|
+
* is forwarded as `timeout`.
|
|
83
|
+
*/
|
|
84
|
+
export function createExecCache(
|
|
85
|
+
host: EvaluatorHost,
|
|
86
|
+
sessionCwd: string,
|
|
87
|
+
): PredicateContext["exec"] {
|
|
88
|
+
const cache = new Map<string, Promise<ExecResult>>();
|
|
89
|
+
return (cmd, args, opts) => {
|
|
90
|
+
const cwd = opts?.cwd ?? sessionCwd;
|
|
91
|
+
const key = execCacheKey(cmd, args, cwd);
|
|
92
|
+
const hit = cache.get(key);
|
|
93
|
+
if (hit !== undefined) return hit;
|
|
94
|
+
const piOpts: PiExecOptions = { cwd };
|
|
95
|
+
if (opts?.timeoutMs !== undefined) piOpts.timeout = opts.timeoutMs;
|
|
96
|
+
const p = host.exec(cmd, args, piOpts).then(toSchemaExecResult);
|
|
97
|
+
cache.set(key, p);
|
|
98
|
+
return p;
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Key under which the engine auto-injects the current `agentLoopIndex`
|
|
104
|
+
* into every entry written via `PredicateContext.appendEntry` or
|
|
105
|
+
* `ObserverContext.appendEntry`. Rules using
|
|
106
|
+
* `when.happened: { in: "agent_loop" }` filter session entries by
|
|
107
|
+
* comparing this key against `ctx.agentLoopIndex`.
|
|
108
|
+
*
|
|
109
|
+
* Part of the on-disk session-JSONL format, exposed as a public
|
|
110
|
+
* module-level constant. Re-exported from the package root
|
|
111
|
+
* so plugin authors who manually inspect entries via
|
|
112
|
+
* `findEntries` can import the constant by name rather than
|
|
113
|
+
* hardcoding the string — a future rename would then break at
|
|
114
|
+
* import time instead of silently producing un-filtered entries.
|
|
115
|
+
*/
|
|
116
|
+
export const AGENT_LOOP_INDEX_KEY = "_agentLoopIndex" as const;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Narrow "is plain object" guard used to distinguish payloads that
|
|
120
|
+
* are safe to merge into (spread) from payloads that must be wrapped
|
|
121
|
+
* as `{ value, _agentLoopIndex }`.
|
|
122
|
+
*
|
|
123
|
+
* Anything that is NOT a plain object (arrays, Date, Map, Set, Error,
|
|
124
|
+
* class instances, functions, null, undefined, primitives) falls into
|
|
125
|
+
* the wrap branch. Direct `{...}` or `Object.create(null)` shapes fall
|
|
126
|
+
* into the merge branch.
|
|
127
|
+
*
|
|
128
|
+
* Two-stage detection:
|
|
129
|
+
* 1. `Object.prototype.toString.call(x)` returns `"[object Object]"`
|
|
130
|
+
* only for plain objects and for class instances of user-defined
|
|
131
|
+
* classes. It correctly excludes arrays, Date, Map, Set, Error,
|
|
132
|
+
* etc.
|
|
133
|
+
* 2. A prototype check then rejects user-class instances: plain
|
|
134
|
+
* objects have `Object.prototype` (or `null` for
|
|
135
|
+
* `Object.create(null)`) as their prototype; `new Box(...)` has
|
|
136
|
+
* `Box.prototype`, which is neither.
|
|
137
|
+
*
|
|
138
|
+
* The explicit `Array.isArray` check is belt-and-suspenders — some
|
|
139
|
+
* runtimes have historically misreported arrays via `toString`, and
|
|
140
|
+
* the array case is the one most likely to hit this wrapper (an
|
|
141
|
+
* observer appending a list of watched paths). Cheap to check twice.
|
|
142
|
+
*/
|
|
143
|
+
export function isPlainObject(x: unknown): x is Record<string, unknown> {
|
|
144
|
+
if (x === null || typeof x !== "object") return false;
|
|
145
|
+
if (Array.isArray(x)) return false;
|
|
146
|
+
if (Object.prototype.toString.call(x) !== "[object Object]") return false;
|
|
147
|
+
const proto = Object.getPrototypeOf(x);
|
|
148
|
+
if (proto !== null && proto !== Object.prototype) return false;
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Wrap a raw `host.appendEntry` so every write auto-injects the
|
|
154
|
+
* current `agentLoopIndex` into the payload. Plain-object payloads
|
|
155
|
+
* get the field merged in; everything else — primitives, arrays,
|
|
156
|
+
* `Date`, `Map`, `Set`, `Error`, class instances, functions, null,
|
|
157
|
+
* undefined — is wrapped as `{ value, _agentLoopIndex }` so
|
|
158
|
+
* downstream consumers always see a consistent object shape.
|
|
159
|
+
*
|
|
160
|
+
* The "everything else" branch exists because the naive spread
|
|
161
|
+
* (`{ ...data, ... }`) silently corrupts non-plain objects: arrays
|
|
162
|
+
* become pseudo-objects with string-indexed keys, Date / Map / Set /
|
|
163
|
+
* Error instances lose their internal state entirely, etc. Wrapping
|
|
164
|
+
* under `value` preserves the original reference unchanged.
|
|
165
|
+
*
|
|
166
|
+
* The returned closure matches both {@link PredicateContext.appendEntry}
|
|
167
|
+
* and {@link ObserverContext.appendEntry} so the evaluator and the
|
|
168
|
+
* observer dispatcher share one wrapper.
|
|
169
|
+
*
|
|
170
|
+
* `findEntriesCache` (optional) is a cache map shared with a sibling
|
|
171
|
+
* {@link createFindEntries} closure. When supplied, every `appendEntry`
|
|
172
|
+
* call invalidates the cache entry for the written `customType` so
|
|
173
|
+
* the next `findEntries(customType)` re-reads the session JSONL and
|
|
174
|
+
* sees the newly-written entry (S2/E1; see ../INVARIANTS.md for the
|
|
175
|
+
* S/E tag glossary). Omit the parameter to keep
|
|
176
|
+
* the pre-S2 behaviour (no invalidation) — handy for tests or callers
|
|
177
|
+
* that don't pair the two closures.
|
|
178
|
+
*/
|
|
179
|
+
export function createAppendEntry(
|
|
180
|
+
host: EvaluatorHost,
|
|
181
|
+
agentLoopIndex: number,
|
|
182
|
+
findEntriesCache?: Map<string, Array<{ data: unknown; timestamp: number }>>,
|
|
183
|
+
): PredicateContext["appendEntry"] {
|
|
184
|
+
return <T>(customType: string, data?: T) => {
|
|
185
|
+
const tagged = isPlainObject(data)
|
|
186
|
+
? { ...data, [AGENT_LOOP_INDEX_KEY]: agentLoopIndex }
|
|
187
|
+
: { value: data, [AGENT_LOOP_INDEX_KEY]: agentLoopIndex };
|
|
188
|
+
host.appendEntry(customType, tagged);
|
|
189
|
+
// S2/E1: drop the cached read for this customType so a later
|
|
190
|
+
// `findEntries(customType)` call from the same phase re-materializes
|
|
191
|
+
// the list and sees the write we just made. Without this, a rule's
|
|
192
|
+
// `onFire` that writes + a later rule's `when.happened` that reads
|
|
193
|
+
// see inconsistent snapshots within one tool_call.
|
|
194
|
+
findEntriesCache?.delete(customType);
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Adapt pi's `sessionManager.getEntries()` into the typed-and-filtered
|
|
200
|
+
* view predicates (and observers) expect.
|
|
201
|
+
*
|
|
202
|
+
* Strategy:
|
|
203
|
+
* - pick only `type: "custom"` entries (the shape `pi.appendEntry`
|
|
204
|
+
* produces — see `CustomEntry` in pi's session-manager),
|
|
205
|
+
* - filter by `customType`,
|
|
206
|
+
* - project to `{ data, timestamp }` where `timestamp` is epoch-ms
|
|
207
|
+
* (parsed from the entry's ISO string). Epoch-ms is what turn-state
|
|
208
|
+
* checks want for chronological comparisons without having to
|
|
209
|
+
* re-parse.
|
|
210
|
+
*
|
|
211
|
+
* Results are memoized PER invocation of `createFindEntries` by
|
|
212
|
+
* customType. The evaluator rebuilds the closure on every tool_call;
|
|
213
|
+
* the observer dispatcher rebuilds on every tool_result. So each phase
|
|
214
|
+
* sees a consistent snapshot across reads.
|
|
215
|
+
*
|
|
216
|
+
* Cross-rule write visibility (S2/E1): when the same phase also uses
|
|
217
|
+
* a paired {@link createAppendEntry} with the SAME cache map, a write
|
|
218
|
+
* during rule A's `onFire` invalidates the cached read for that
|
|
219
|
+
* customType so rule B's `when.happened` predicate sees the fresh
|
|
220
|
+
* entry. Callers that want this consistency pass in a shared cache
|
|
221
|
+
* via the optional `cache` parameter; callers that omit it get the
|
|
222
|
+
* old per-closure snapshot behaviour (pre-S2), which is sound only
|
|
223
|
+
* when the closure never interleaves reads with writes.
|
|
224
|
+
*
|
|
225
|
+
* The `ctx` argument is the pi `ExtensionContext` — we re-read
|
|
226
|
+
* `getEntries()` only on a cache miss. Cache keys are per-closure (or
|
|
227
|
+
* per shared cache) so cross-tool_call or cross-tool_result reads
|
|
228
|
+
* always see the freshest state (a new closure = a new cache).
|
|
229
|
+
*/
|
|
230
|
+
export function createFindEntries(
|
|
231
|
+
ctx: ExtensionContext,
|
|
232
|
+
cache?: Map<string, Array<{ data: unknown; timestamp: number }>>,
|
|
233
|
+
): PredicateContext["findEntries"] {
|
|
234
|
+
const entryCache =
|
|
235
|
+
cache ?? new Map<string, Array<{ data: unknown; timestamp: number }>>();
|
|
236
|
+
return <T>(customType: string) => {
|
|
237
|
+
const hit = entryCache.get(customType);
|
|
238
|
+
if (hit !== undefined) {
|
|
239
|
+
return hit as Array<{ data: T; timestamp: number }>;
|
|
240
|
+
}
|
|
241
|
+
const out: Array<{ data: T; timestamp: number }> = [];
|
|
242
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
243
|
+
if (entry.type !== "custom") continue;
|
|
244
|
+
if (entry.customType !== customType) continue;
|
|
245
|
+
const ts = Date.parse(entry.timestamp);
|
|
246
|
+
out.push({
|
|
247
|
+
data: entry.data as T,
|
|
248
|
+
timestamp: Number.isNaN(ts) ? 0 : ts,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
entryCache.set(
|
|
252
|
+
customType,
|
|
253
|
+
out as Array<{ data: unknown; timestamp: number }>,
|
|
254
|
+
);
|
|
255
|
+
return out;
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Allocate a fresh session-entry cache shared between a paired
|
|
261
|
+
* {@link createFindEntries} + {@link createAppendEntry} for the same
|
|
262
|
+
* tool_call (evaluator) or tool_result (observer dispatcher) phase.
|
|
263
|
+
*
|
|
264
|
+
* Using a shared cache gives two guarantees the evaluator + dispatcher
|
|
265
|
+
* rely on:
|
|
266
|
+
*
|
|
267
|
+
* 1. Consistent reads: N calls to `findEntries(type)` within one
|
|
268
|
+
* phase materialize the entry list ONCE per type.
|
|
269
|
+
* 2. Write-through-reads (S2/E1): a write via the paired
|
|
270
|
+
* `appendEntry` invalidates that type's cached list, so the next
|
|
271
|
+
* read re-scans the session JSONL and observes the write. Without
|
|
272
|
+
* this, a rule's `onFire` appending X followed by a later rule's
|
|
273
|
+
* `when.happened: { event: X }` would read a stale pre-write
|
|
274
|
+
* snapshot.
|
|
275
|
+
*
|
|
276
|
+
* Consumers who don't need write-through-reads (tests, one-shot
|
|
277
|
+
* `findEntries` calls) can pass a fresh cache or omit the parameter
|
|
278
|
+
* on both constructors — the closures then each get their own cache
|
|
279
|
+
* map and behave like the pre-S2 implementation.
|
|
280
|
+
*/
|
|
281
|
+
export function createSessionEntryCache(): Map<
|
|
282
|
+
string,
|
|
283
|
+
Array<{ data: unknown; timestamp: number }>
|
|
284
|
+
> {
|
|
285
|
+
return new Map<string, Array<{ data: unknown; timestamp: number }>>();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Silence a re-import of ExecOpts that older linters flag (we only use
|
|
289
|
+
// the type via PredicateContext["exec"] signature above).
|
|
290
|
+
export type { ExecOpts };
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Part of pi-steering.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Unit tests for `extractOverride` — the inline override-comment
|
|
6
|
+
* detector the v2 evaluator uses to surface `# steering-override:
|
|
7
|
+
* <rule> — <reason>` annotations on bash / write / edit inputs.
|
|
8
|
+
*
|
|
9
|
+
* Ported from the v1 suite in `../../evaluator.test.ts` (the
|
|
10
|
+
* `describe("extractOverride", ...)` block) so the v2 module is
|
|
11
|
+
* directly pinned against the v1 behavior it claims parity with.
|
|
12
|
+
* Phase 3c deletes v1; these tests preserve every documented regex
|
|
13
|
+
* edge case independently of the evaluator pipeline.
|
|
14
|
+
*
|
|
15
|
+
* Each test targets one axis of the override grammar (leader
|
|
16
|
+
* character, separator character, empty reason, stacked overrides,
|
|
17
|
+
* mismatched rule name, …). Kept as a flat `describe` block to match
|
|
18
|
+
* the v1 shape for diff-friendly porting.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import assert from "node:assert/strict";
|
|
22
|
+
import { describe, it } from "node:test";
|
|
23
|
+
import { extractOverride } from "./override.ts";
|
|
24
|
+
|
|
25
|
+
describe("extractOverride", () => {
|
|
26
|
+
it("extracts reason from a hash-leader override", () => {
|
|
27
|
+
const r = extractOverride(
|
|
28
|
+
"git push --force # steering-override: no-force-push \u2014 coordinated rewrite",
|
|
29
|
+
"no-force-push",
|
|
30
|
+
);
|
|
31
|
+
assert.equal(r, "coordinated rewrite");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("extracts reason from a slash-leader override", () => {
|
|
35
|
+
const r = extractOverride(
|
|
36
|
+
"// steering-override: no-console \u2014 debug session only",
|
|
37
|
+
"no-console",
|
|
38
|
+
);
|
|
39
|
+
assert.equal(r, "debug session only");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("accepts em dash, en dash, and hyphen as separators", () => {
|
|
43
|
+
assert.equal(
|
|
44
|
+
extractOverride("# steering-override: r \u2014 em", "r"),
|
|
45
|
+
"em",
|
|
46
|
+
);
|
|
47
|
+
assert.equal(
|
|
48
|
+
extractOverride("# steering-override: r \u2013 en", "r"),
|
|
49
|
+
"en",
|
|
50
|
+
);
|
|
51
|
+
assert.equal(
|
|
52
|
+
extractOverride("# steering-override: r - hyphen", "r"),
|
|
53
|
+
"hyphen",
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("returns null when no override is present", () => {
|
|
58
|
+
assert.equal(extractOverride("git push --force", "no-force-push"), null);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("returns null when the override targets a different rule", () => {
|
|
62
|
+
const r = extractOverride(
|
|
63
|
+
"# steering-override: other-rule \u2014 reason",
|
|
64
|
+
"no-force-push",
|
|
65
|
+
);
|
|
66
|
+
assert.equal(r, null);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("returns null when reason is empty", () => {
|
|
70
|
+
const r = extractOverride("# steering-override: r \u2014 ", "r");
|
|
71
|
+
assert.equal(r, null);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("stacked overrides: looking up first rule returns its reason only", () => {
|
|
75
|
+
const text =
|
|
76
|
+
"cmd # steering-override: rule-a \u2014 reason-a # steering-override: rule-b \u2014 reason-b";
|
|
77
|
+
assert.equal(extractOverride(text, "rule-a"), "reason-a");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("stacked overrides: looking up second rule returns its reason only", () => {
|
|
81
|
+
const text =
|
|
82
|
+
"cmd # steering-override: rule-a \u2014 reason-a # steering-override: rule-b \u2014 reason-b";
|
|
83
|
+
assert.equal(extractOverride(text, "rule-b"), "reason-b");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("stacked overrides: unrelated lookup returns null (no bleed from either)", () => {
|
|
87
|
+
const text =
|
|
88
|
+
"cmd # steering-override: rule-a \u2014 reason-a # steering-override: rule-b \u2014 reason-b";
|
|
89
|
+
assert.equal(extractOverride(text, "rule-c"), null);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("stacked overrides: empty reason on first is skipped, scanner finds second match for same rule", () => {
|
|
93
|
+
// First `foo` override has no reason (whitespace only). The scanner
|
|
94
|
+
// must keep going and surface the second `foo` override's reason.
|
|
95
|
+
const text =
|
|
96
|
+
"# steering-override: foo \u2014 # steering-override: foo \u2014 actual reason";
|
|
97
|
+
assert.equal(extractOverride(text, "foo"), "actual reason");
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Part of pi-steering.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Inline override-comment detection for the v2 evaluator.
|
|
6
|
+
*
|
|
7
|
+
* Ported verbatim from the v1 evaluator's `extractOverride` (see
|
|
8
|
+
* `../../evaluator.ts`). Syntax and behaviour match exactly so the
|
|
9
|
+
* extension runtime can swap from v1 to v2 without users rewriting
|
|
10
|
+
* their override comments. See the v1 evaluator's JSDoc for the full
|
|
11
|
+
* syntax grammar.
|
|
12
|
+
*
|
|
13
|
+
* Exposed as a standalone module so the evaluator and its tests can
|
|
14
|
+
* assert override detection independently of the rest of the pipeline.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Global-flag regex matching every override comment in a text blob.
|
|
19
|
+
*
|
|
20
|
+
* leader: `#`, `//`, `/*`, `<!--`, `--`, `%%`, `;;`
|
|
21
|
+
* key: `steering-override:`
|
|
22
|
+
* name: `[A-Za-z0-9_-]+`
|
|
23
|
+
* sep: `—` (em dash), `–` (en dash), or `-` (hyphen)
|
|
24
|
+
* reason: anything up to `*\u002f`, `-->`, or end of line
|
|
25
|
+
*/
|
|
26
|
+
const OVERRIDE_RE =
|
|
27
|
+
/(?:#|\/\/|\/\*|<!--|--|%%|;;)\s*steering-override:\s*([A-Za-z0-9_-]+)\s*[\u2014\u2013-]\s*(.*?)(?:\*\/|-->|$)/gm;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Shorter marker-only regex used to detect when the lazy reason capture
|
|
31
|
+
* in {@link OVERRIDE_RE} swallowed a subsequent override marker. When the
|
|
32
|
+
* scanner finds one, it trims the reason at the marker and rewinds so
|
|
33
|
+
* the next iteration parses the subsequent override cleanly.
|
|
34
|
+
*/
|
|
35
|
+
const OVERRIDE_MARKER_RE = /(?:#|\/\/|\/\*|<!--|--|%%|;;)\s*steering-override:/;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Extract an inline override-comment's reason that targets a specific
|
|
39
|
+
* rule name. Returns the trimmed reason, or null when no override
|
|
40
|
+
* addressed to `ruleName` is present (empty reasons are treated as
|
|
41
|
+
* "no override" — users must supply an explicit justification).
|
|
42
|
+
*
|
|
43
|
+
* Behaviour parity with v1 `extractOverride`:
|
|
44
|
+
*
|
|
45
|
+
* - case-sensitive rule-name match,
|
|
46
|
+
* - supports stacked overrides on one line (e.g.
|
|
47
|
+
* `cmd # steering-override: a — r1 # steering-override: b — r2`),
|
|
48
|
+
* - first-match-wins across the scanned comments,
|
|
49
|
+
* - all leader styles from v1 accepted.
|
|
50
|
+
*
|
|
51
|
+
* The v1 implementation is the authoritative reference for edge cases
|
|
52
|
+
* — see `evaluator.ts` in this package's v1 tree.
|
|
53
|
+
*/
|
|
54
|
+
export function extractOverride(text: string, ruleName: string): string | null {
|
|
55
|
+
// Reset `lastIndex` — the regex is module-scoped and shared across
|
|
56
|
+
// calls. Without the reset two back-to-back calls with different
|
|
57
|
+
// texts would pick up mid-string from the previous scan.
|
|
58
|
+
OVERRIDE_RE.lastIndex = 0;
|
|
59
|
+
let m = OVERRIDE_RE.exec(text);
|
|
60
|
+
while (m !== null) {
|
|
61
|
+
let reason = m[2] ?? "";
|
|
62
|
+
// Compute where the reason capture started in the source so we
|
|
63
|
+
// can rewind the scanner if we trim a trailing next-override
|
|
64
|
+
// marker out of the lazy capture.
|
|
65
|
+
const reasonStart = (m.index ?? 0) + m[0].length - reason.length;
|
|
66
|
+
const nextIdx = reason.search(OVERRIDE_MARKER_RE);
|
|
67
|
+
if (nextIdx !== -1) {
|
|
68
|
+
reason = reason.slice(0, nextIdx);
|
|
69
|
+
OVERRIDE_RE.lastIndex = reasonStart + nextIdx;
|
|
70
|
+
}
|
|
71
|
+
if (m[1] !== ruleName) {
|
|
72
|
+
m = OVERRIDE_RE.exec(text);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
reason = reason.trim();
|
|
76
|
+
if (reason === "") {
|
|
77
|
+
m = OVERRIDE_RE.exec(text);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
return reason;
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|