@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,62 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Part of the pi-steering work-item-plugin example.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `push-requires-tests` — example rule.
|
|
6
|
+
*
|
|
7
|
+
* Demonstrates three v0.1.0 engine features in one rule:
|
|
8
|
+
*
|
|
9
|
+
* 1. Observer → rule coupling via shared constants (ADR §5, §14).
|
|
10
|
+
* `npm-test-tracker` writes `TEST_PASSED_EVENT` on every
|
|
11
|
+
* successful `npm test`; this rule gates `git push` on
|
|
12
|
+
* `when.happened`, which fires when the event has NOT happened.
|
|
13
|
+
*
|
|
14
|
+
* 2. Temporal invalidation via `since` (PR §4). A separate observer
|
|
15
|
+
* `retest-required-tracker` writes `RETEST_REQUIRED_EVENT` on
|
|
16
|
+
* every `git pull`. Even if tests passed earlier in the loop, a
|
|
17
|
+
* subsequent pull stale-s the test state — the rule fires again,
|
|
18
|
+
* forcing a re-run against the updated tree.
|
|
19
|
+
*
|
|
20
|
+
* 3. `&&`-chain speculative allow (PR §4). Because the engine
|
|
21
|
+
* sees `npm-test-tracker`'s `writes: [TEST_PASSED_EVENT]`, it
|
|
22
|
+
* treats `npm test && git push` as safe: the push is gated on
|
|
23
|
+
* the prior `&&` ref, and `&&` short-circuits on test failure.
|
|
24
|
+
* This breaks the "block → agent retries same chain → block"
|
|
25
|
+
* loop without weakening the guardrail for non-chained pushes.
|
|
26
|
+
*
|
|
27
|
+
* `in: "agent_loop"` scopes everything to the current user prompt +
|
|
28
|
+
* its tool calls. Running tests in a prior agent loop doesn't let
|
|
29
|
+
* you push in this one.
|
|
30
|
+
*
|
|
31
|
+
* Override: disallowed. Pushing without proof of green tests is an
|
|
32
|
+
* inherent-risk action here.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import type { Rule } from "@cad0p/pi-steering";
|
|
36
|
+
import { TEST_PASSED_EVENT } from "../observers/npm-test-tracker.ts";
|
|
37
|
+
import { RETEST_REQUIRED_EVENT } from "../observers/retest-required-tracker.ts";
|
|
38
|
+
|
|
39
|
+
export const pushRequiresTests = {
|
|
40
|
+
name: "push-requires-tests",
|
|
41
|
+
tool: "bash",
|
|
42
|
+
field: "command",
|
|
43
|
+
pattern: /^git\s+push\b/,
|
|
44
|
+
when: {
|
|
45
|
+
// Fires when TEST_PASSED_EVENT has NOT been written in the
|
|
46
|
+
// current agent loop, OR its most-recent entry is older than
|
|
47
|
+
// the most-recent RETEST_REQUIRED_EVENT (e.g. a later `git pull`
|
|
48
|
+
// stale-d the test state).
|
|
49
|
+
happened: {
|
|
50
|
+
event: TEST_PASSED_EVENT,
|
|
51
|
+
in: "agent_loop",
|
|
52
|
+
since: RETEST_REQUIRED_EVENT,
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
reason:
|
|
56
|
+
"Run `npm test` successfully in this agent loop before pushing. " +
|
|
57
|
+
"If you ran `git pull` after the last test, re-run tests.",
|
|
58
|
+
noOverride: true,
|
|
59
|
+
// Declaring the observer's write-type in our `writes` is NOT
|
|
60
|
+
// required — the observer already declares it. This rule doesn't
|
|
61
|
+
// write anything, so no `writes` here.
|
|
62
|
+
} as const satisfies Rule;
|
package/package.json
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cad0p/pi-steering",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "AST-backed steering hooks for pi \u2014 deterministic tool-call guardrails with command-level effective-cwd scoping.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package",
|
|
8
|
+
"extension",
|
|
9
|
+
"steering",
|
|
10
|
+
"guardrails",
|
|
11
|
+
"safety",
|
|
12
|
+
"hooks",
|
|
13
|
+
"ast",
|
|
14
|
+
"bash"
|
|
15
|
+
],
|
|
16
|
+
"type": "module",
|
|
17
|
+
"main": "./dist/index.js",
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"bin": {
|
|
20
|
+
"pi-steering": "./dist/bin/pi-steering.js"
|
|
21
|
+
},
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"import": "./dist/index.js"
|
|
26
|
+
},
|
|
27
|
+
"./plugins/git": {
|
|
28
|
+
"types": "./dist/plugins/git/index.d.ts",
|
|
29
|
+
"import": "./dist/plugins/git/index.js"
|
|
30
|
+
},
|
|
31
|
+
"./testing": {
|
|
32
|
+
"types": "./dist/testing/index.d.ts",
|
|
33
|
+
"import": "./dist/testing/index.js"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"dist",
|
|
38
|
+
"src",
|
|
39
|
+
"examples",
|
|
40
|
+
"skills",
|
|
41
|
+
"README.md",
|
|
42
|
+
"CHANGELOG.md",
|
|
43
|
+
"LICENSE"
|
|
44
|
+
],
|
|
45
|
+
"repository": {
|
|
46
|
+
"type": "git",
|
|
47
|
+
"url": "git+https://github.com/cad0p/pi-steering.git"
|
|
48
|
+
},
|
|
49
|
+
"license": "MIT",
|
|
50
|
+
"pi": {
|
|
51
|
+
"extensions": [
|
|
52
|
+
"./src/index.ts"
|
|
53
|
+
],
|
|
54
|
+
"skills": [
|
|
55
|
+
"./skills"
|
|
56
|
+
]
|
|
57
|
+
},
|
|
58
|
+
"engines": {
|
|
59
|
+
"node": ">=22"
|
|
60
|
+
},
|
|
61
|
+
"scripts": {
|
|
62
|
+
"clean": "rm -rf dist tsconfig.build.tsbuildinfo",
|
|
63
|
+
"build": "tsc -p tsconfig.build.json",
|
|
64
|
+
"prepare": "pnpm build",
|
|
65
|
+
"test": "node --test --experimental-strip-types 'src/**/*.test.ts'",
|
|
66
|
+
"test:examples": "pnpm -r test",
|
|
67
|
+
"typecheck": "tsc --noEmit",
|
|
68
|
+
"typecheck:examples": "pnpm -r typecheck",
|
|
69
|
+
"lint": "biome check src/ examples/ skills/ scripts/",
|
|
70
|
+
"lint:fix": "biome check --write src/ examples/ skills/ scripts/",
|
|
71
|
+
"smoke": "node scripts/smoke.mjs"
|
|
72
|
+
},
|
|
73
|
+
"dependencies": {
|
|
74
|
+
"unbash": "^3.0.0",
|
|
75
|
+
"@cad0p/unbash-walker": "^0.1.0"
|
|
76
|
+
},
|
|
77
|
+
"peerDependencies": {
|
|
78
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
79
|
+
},
|
|
80
|
+
"devDependencies": {
|
|
81
|
+
"@biomejs/biome": "^2.3.14",
|
|
82
|
+
"@types/node": "^20.19.39",
|
|
83
|
+
"typescript": "^5.4.5"
|
|
84
|
+
},
|
|
85
|
+
"publishConfig": {
|
|
86
|
+
"access": "public"
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: steering-authoring
|
|
3
|
+
description: Author declarative steering rules for pi-steering. Use when the user asks to block or allow agent tool calls, write guardrails for bash/write/edit, author pi steering rules, add rule plugins, or convert a JSON steering config to the TypeScript config.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# pi-steering
|
|
7
|
+
|
|
8
|
+
You have `@cad0p/pi-steering` installed. It blocks and allows agent tool calls (bash, write, edit) via declarative rules authored in TypeScript.
|
|
9
|
+
|
|
10
|
+
## Where things live
|
|
11
|
+
|
|
12
|
+
- Rules: `.pi/steering/index.ts` (directory form) or `.pi/steering.ts` (single-file form).
|
|
13
|
+
- Local plugins: `.pi/steering/plugins/*.ts`, imported into `index.ts`.
|
|
14
|
+
- Tests: `.pi/steering/*.test.ts` using `@cad0p/pi-steering/testing`.
|
|
15
|
+
|
|
16
|
+
The loader walks up from `cwd` to the nearest `.pi/` dir, falling back to `~/.pi/`. See the README for the full precedence order.
|
|
17
|
+
|
|
18
|
+
## Common operations
|
|
19
|
+
|
|
20
|
+
| User says | You do |
|
|
21
|
+
|---|---|
|
|
22
|
+
| "block X" | Add a `Rule` with `tool`, `field`, and `pattern` to the `rules` array. |
|
|
23
|
+
| "block X only in dir Y" | Add `when: { cwd: /Y/ }` to the rule. The walker resolves `cd "$WS/pkg"`-style dynamic targets through `envTracker`; intractable targets (unknown `$VAR`, command substitution) surface as `"unknown"` and fire `onUnknown: "block"` by default. |
|
|
24
|
+
| "block X unless on branch Z" | `when: { not: { branch: /Z/ } }` — requires the git plugin. |
|
|
25
|
+
| "block X unless `--flag`" | `unless: /--flag\b/`. |
|
|
26
|
+
| "require Y before X" | Observer that `appendEntry`s a marker, plus a rule whose `when.happened` gates on it (`{ event, in: "agent_loop" }`). Prefer this to hand-rolled `findEntries` + `agentLoopIndex` comparisons — same semantics, less code. |
|
|
27
|
+
| "require Y in a **prior** tool_call, not same-chain" | `when: { happened: { event, in: "agent_loop", notIn: "tool_call" } }`. `notIn` is scope subtraction — it removes the narrower scope from the broader one, so `&&`-chain bypass is blocked. Distinct from clause-level `not` (boolean negation). |
|
|
28
|
+
| "invalidate Y when Z happens" | `when: { happened: { event: Y_EVENT, in: "agent_loop", since: Z_EVENT } }`. Y only counts if its latest entry is newer than Z's latest entry in scope. If Z never happened, the clause degrades to a simple presence check. |
|
|
29
|
+
| "put runtime state in the block message" | `reason: (ctx) => \`blocked at \${ctx.walkerState?.cwd}\`` — `Rule.reason` accepts a sync or async function. The engine awaits it and prefixes with `[steering:name@source]` like a string reason. If it throws, a fail-safe fallback text fires and the error is logged to `console.warn`; the block still fires. |
|
|
30
|
+
| "add a custom check" | Write a plugin in `.pi/steering/plugins/`, import it into `index.ts`, register it in `plugins: [...]`. |
|
|
31
|
+
| "change the reason on a built-in rule" | Import the original rule from its plugin, spread it with `{ ...original, name: "new-name", reason: "..." }`, and use `disabledRules: ["original-name"]` + add the replacement. Preserves pattern / when / observer. |
|
|
32
|
+
| "test this rule" | Create `steering.test.ts` using `expectBlocks` / `expectAllows` / `loadHarness`. |
|
|
33
|
+
| "convert my JSON config to TypeScript" | Run `pi-steering import-json .pi/steering.json -o .pi/steering.ts`. Plugins, observers, and function predicates don't round-trip — author those directly in TS. |
|
|
34
|
+
| "publish a pi-steering plugin" | Package as `pi-steering-<domain>` (unscoped) with `keywords: ["pi-package", "pi-steering-package"]` in package.json. peerDep on `@cad0p/pi-steering`. |
|
|
35
|
+
|
|
36
|
+
## Minimal config
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
import { defineConfig } from "@cad0p/pi-steering";
|
|
40
|
+
|
|
41
|
+
export default defineConfig({
|
|
42
|
+
rules: [
|
|
43
|
+
{
|
|
44
|
+
name: "no-dangerous-command",
|
|
45
|
+
tool: "bash",
|
|
46
|
+
field: "command",
|
|
47
|
+
pattern: /^dangerous-command\b/,
|
|
48
|
+
reason: "don't run this",
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
});
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`DEFAULT_RULES` (e.g. `no-force-push`, `no-rm-rf-slash`) are included automatically. `DEFAULT_PLUGINS` is empty by design — plugins are opt-in (declare `plugins: [gitPlugin]`), which keeps `defineConfig`'s type unions (typo-checking on `disabledRules` / `disabledPlugins`) in sync with what's actually loaded. Disable defaults via `disabledRules: ["name"]` or opt out entirely with `disableDefaults: true`.
|
|
55
|
+
|
|
56
|
+
## Git plugin (branch / upstream / commits-ahead predicates)
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
import { defineConfig } from "@cad0p/pi-steering";
|
|
60
|
+
import gitPlugin from "@cad0p/pi-steering/plugins/git";
|
|
61
|
+
|
|
62
|
+
export default defineConfig({
|
|
63
|
+
plugins: [gitPlugin],
|
|
64
|
+
rules: [
|
|
65
|
+
{
|
|
66
|
+
name: "no-main-push",
|
|
67
|
+
tool: "bash",
|
|
68
|
+
field: "command",
|
|
69
|
+
pattern: /^git\s+push\b/,
|
|
70
|
+
when: { branch: /^(main|master|mainline|trunk)$/ },
|
|
71
|
+
reason: "don't push from main",
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
});
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Testing
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
// .pi/steering/steering.test.ts
|
|
81
|
+
import { describe, it } from "node:test";
|
|
82
|
+
import { expectAllows, expectBlocks, loadHarness } from "@cad0p/pi-steering/testing";
|
|
83
|
+
import config from "./index.ts";
|
|
84
|
+
|
|
85
|
+
describe("my steering config", () => {
|
|
86
|
+
const harness = loadHarness({ config, includeDefaults: true });
|
|
87
|
+
|
|
88
|
+
it("blocks dangerous-command", async () => {
|
|
89
|
+
await expectBlocks(harness, { command: "dangerous-command run" });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("allows safe commands", async () => {
|
|
93
|
+
await expectAllows(harness, { command: "ls" });
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Run with `node --test --experimental-strip-types '.pi/steering/**/*.test.ts'`.
|
|
99
|
+
|
|
100
|
+
## Details
|
|
101
|
+
|
|
102
|
+
Read [the package README](../../README.md) for the full schema, plugin authoring, observer / turn-state patterns, override semantics, and the JSON-to-TS conversion surface.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# Internal invariants — `S`, `E`, and `O` tags
|
|
2
|
+
|
|
3
|
+
Short reference for the load-bearing invariants flagged across the
|
|
4
|
+
engine source. These are NOT a public API contract — they are
|
|
5
|
+
maintainer shorthand to keep related call sites traceable across
|
|
6
|
+
files. Source comments cite the tag and rely on this file for the
|
|
7
|
+
definition.
|
|
8
|
+
|
|
9
|
+
This file ships with the package as a maintainer-facing glossary;
|
|
10
|
+
it is not part of the public API surface (no exports), but is
|
|
11
|
+
included in the npm tarball for source-readers consulting tagged
|
|
12
|
+
call sites on disk.
|
|
13
|
+
|
|
14
|
+
## Safety invariants (`S`)
|
|
15
|
+
|
|
16
|
+
The engine evaluates user-authored steering rules against
|
|
17
|
+
LLM-proposed tool calls. The `S` invariants are the layered defenses
|
|
18
|
+
that keep a buggy or malformed plugin / rule from silently
|
|
19
|
+
failing OPEN the gate.
|
|
20
|
+
|
|
21
|
+
### `S1` — fail-closed isolation
|
|
22
|
+
|
|
23
|
+
**Where:** `evaluator.ts` (`evaluateEvent` top-level wrap; per-rule
|
|
24
|
+
try/catch), `evaluator-internals/predicates.ts` (per-predicate
|
|
25
|
+
try/catch in `runPredicateChain`).
|
|
26
|
+
|
|
27
|
+
A predicate that throws — built-in or plugin-supplied, sync or async
|
|
28
|
+
— is treated as "rule does not fire", logged via `console.warn` with
|
|
29
|
+
the rule name + `@<source>` tag + key, and evaluation continues with
|
|
30
|
+
the next rule. The top-level wrap in `evaluator.ts` is the outermost
|
|
31
|
+
catch: if the engine's own scaffolding throws (parse errors, walker
|
|
32
|
+
bugs, corrupted session JSONL), the tool is BLOCKED with an
|
|
33
|
+
engine-tagged reason so the agent sees the throw came from the
|
|
34
|
+
engine, not from a rule.
|
|
35
|
+
|
|
36
|
+
The same pattern applies to observers in `observer-dispatcher.ts` —
|
|
37
|
+
a throwing observer is isolated to its own dispatch and never
|
|
38
|
+
escalates.
|
|
39
|
+
|
|
40
|
+
### `S2` — write-through-read consistency
|
|
41
|
+
|
|
42
|
+
**Where:** `evaluator-internals/context.ts` (`createAppendEntry` /
|
|
43
|
+
`createFindEntries` paired-cache invariant).
|
|
44
|
+
|
|
45
|
+
When `createAppendEntry` and `createFindEntries` share the same
|
|
46
|
+
cache map (the evaluator wires them this way per tool_call; the
|
|
47
|
+
observer-dispatcher wires them per tool_result),
|
|
48
|
+
`createAppendEntry` invalidates the cache entry for the written
|
|
49
|
+
`customType` so the next paired `createFindEntries` call re-reads.
|
|
50
|
+
Callers omitting the shared cache get per-closure snapshot behavior.
|
|
51
|
+
|
|
52
|
+
### `S3` — name validation
|
|
53
|
+
|
|
54
|
+
**Where:** `plugin-merger.ts` (`validateName`; production pipeline
|
|
55
|
+
via `validateUserConfigNames` + `resolvePlugins`), `evaluator.ts`
|
|
56
|
+
(defensive throw in `buildEvaluator`), `observer-dispatcher.ts`
|
|
57
|
+
(defensive throw in `buildObserverDispatcher`).
|
|
58
|
+
|
|
59
|
+
Rule / plugin / observer names flow into user-visible strings — the
|
|
60
|
+
`[steering:<name>@<source>]` block-reason tag shown to the LLM, the
|
|
61
|
+
`@<source>` tag in warning logs, override-comment target matching,
|
|
62
|
+
`disabledRules` / `disabledPlugins` config references. Names
|
|
63
|
+
containing whitespace, control characters, `]`, or newlines let a
|
|
64
|
+
malicious or careless config author forge block reasons that
|
|
65
|
+
deceive the agent.
|
|
66
|
+
|
|
67
|
+
Validated at production call sites (`validateUserConfigNames`,
|
|
68
|
+
`resolvePlugins`) and as defense-in-depth at `buildEvaluator` /
|
|
69
|
+
`buildObserverDispatcher` — see per-site JSDocs.
|
|
70
|
+
|
|
71
|
+
## Evaluation invariants (`E`)
|
|
72
|
+
|
|
73
|
+
### `E1` — cross-rule write visibility within a phase
|
|
74
|
+
|
|
75
|
+
**Where:** paired-cache wiring in `evaluator.ts` and
|
|
76
|
+
`observer-dispatcher.ts`; cache mechanism in
|
|
77
|
+
`evaluator-internals/context.ts`.
|
|
78
|
+
|
|
79
|
+
Within a single tool_call (or tool_result) phase, rule B's
|
|
80
|
+
`when.happened` predicate MUST see entries rule A's `onFire` wrote
|
|
81
|
+
earlier in the same phase. Implementation: shared cache invalidation
|
|
82
|
+
per `S2`.
|
|
83
|
+
|
|
84
|
+
## Orchestration invariants (`O`)
|
|
85
|
+
|
|
86
|
+
### `O1` — observer-drop parity between runtime and CLI
|
|
87
|
+
|
|
88
|
+
**Where:** `internal/session-runtime.ts` (`buildSessionRuntime` →
|
|
89
|
+
`finalizePluginState`); `bin/pi-steering.ts`
|
|
90
|
+
(`runCliMergeWithInfoCapture`).
|
|
91
|
+
|
|
92
|
+
**What:** Both surfaces apply `disabledRules` filtering BEFORE
|
|
93
|
+
running `dropUnusedObservers`, so an observer whose only consumers
|
|
94
|
+
are disabled rules surfaces the same `console.info` breadcrumb in
|
|
95
|
+
both paths. A future surface that bypasses this ordering would see
|
|
96
|
+
different observer-drop behavior than the runtime.
|
|
97
|
+
|
|
98
|
+
**Pinned by:** `internal/session-runtime.test.ts` (runtime branch);
|
|
99
|
+
`bin/pi-steering.test.ts` (CLI branch).
|
|
100
|
+
|
|
101
|
+
### `O2` — single-emission lock for cross-detector tracker-name collisions
|
|
102
|
+
|
|
103
|
+
**Where:** `internal/session-runtime.ts` (`runMergerPipeline`);
|
|
104
|
+
`loader.ts` (`detectTrackerNameCollisions`); `plugin-merger.ts`
|
|
105
|
+
(`resolvePlugins`).
|
|
106
|
+
|
|
107
|
+
**What:** Both `buildConfig` (loader-side) and `resolvePlugins`
|
|
108
|
+
(merger-side) independently detect tracker-name collisions.
|
|
109
|
+
`runMergerPipeline` short-circuits before invoking `resolvePlugins`
|
|
110
|
+
when any merge-side diagnostic is error-class, so the aggregated
|
|
111
|
+
error message lists each tracker-name collision exactly once.
|
|
112
|
+
|
|
113
|
+
**Pinned by:** `internal/session-runtime.test.ts` "throws on an
|
|
114
|
+
error-class diagnostic regardless of failOnWarnings" (single-emission
|
|
115
|
+
lock); `factory-time-load.test.ts` "throws on tracker-name-collision"
|
|
116
|
+
(integration mirror).
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Part of pi-steering.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared test-double helpers.
|
|
6
|
+
*
|
|
7
|
+
* `evaluator.test.ts` and `observer-dispatcher.test.ts` both need:
|
|
8
|
+
*
|
|
9
|
+
* - a minimal {@link ExtensionContext} stub whose `sessionManager`
|
|
10
|
+
* only exposes `getEntries()` (everything else throws on access),
|
|
11
|
+
* - a "tracked host" {@link EvaluatorHost} that records every
|
|
12
|
+
* `exec` / `appendEntry` call plus pushes `appendEntry` payloads
|
|
13
|
+
* into an entries array shaped like the pi session JSONL, so the
|
|
14
|
+
* same array can back a `makeCtx` stub and let tests assert
|
|
15
|
+
* cross-handler `findEntries` visibility.
|
|
16
|
+
*
|
|
17
|
+
* The two former copies diverged only in whether `makeHost` accepted an
|
|
18
|
+
* `exec` override (evaluator tests need it to count child-process
|
|
19
|
+
* invocations for the memoization assertions; observer tests don't).
|
|
20
|
+
* That's now a single option on the unified helper.
|
|
21
|
+
*
|
|
22
|
+
* Kept OUT of the public surface: `__test-helpers__` is a leading-double-
|
|
23
|
+
* underscore convention indicating "test only"; nothing under `src/`
|
|
24
|
+
* imports it at runtime.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
mkdirSync,
|
|
29
|
+
mkdtempSync,
|
|
30
|
+
realpathSync,
|
|
31
|
+
rmSync,
|
|
32
|
+
writeFileSync,
|
|
33
|
+
} from "node:fs";
|
|
34
|
+
import { tmpdir } from "node:os";
|
|
35
|
+
import { join } from "node:path";
|
|
36
|
+
import { afterEach, beforeEach } from "node:test";
|
|
37
|
+
import type {
|
|
38
|
+
ExtensionContext,
|
|
39
|
+
ExecResult as PiExecResult,
|
|
40
|
+
} from "@earendil-works/pi-coding-agent";
|
|
41
|
+
import type { EvaluatorHost } from "./evaluator-internals/context.ts";
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Isolated $HOME fixture
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Per-test scratch `$HOME` fixture. Registers `beforeEach` /
|
|
49
|
+
* `afterEach` that:
|
|
50
|
+
*
|
|
51
|
+
* - `mkdtempSync` a fresh temp dir using `prefix`,
|
|
52
|
+
* - save `process.env["HOME"]`, point it at the temp dir,
|
|
53
|
+
* - restore `process.env["HOME"]` and recursively remove the temp
|
|
54
|
+
* dir on teardown.
|
|
55
|
+
*
|
|
56
|
+
* Used by every test surface that exercises the loader walk-up
|
|
57
|
+
* (`index.test.ts`, `loader.test.ts`, `internal/session-runtime.test.ts`)
|
|
58
|
+
* so the per-file scratch-HOME boilerplate stays in one place.
|
|
59
|
+
*
|
|
60
|
+
* The temp dir path is exposed via the optional `onReady` callback,
|
|
61
|
+
* fired inside `beforeEach`; tests typically stash it in a
|
|
62
|
+
* describe-scoped `let` for terser reads.
|
|
63
|
+
*/
|
|
64
|
+
export function useIsolatedHome(
|
|
65
|
+
prefix: string,
|
|
66
|
+
onReady?: (tmp: string) => void,
|
|
67
|
+
): void {
|
|
68
|
+
let tmp: string;
|
|
69
|
+
let priorHome: string | undefined;
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
tmp = mkdtempSync(join(tmpdir(), prefix));
|
|
72
|
+
priorHome = process.env["HOME"];
|
|
73
|
+
process.env["HOME"] = tmp;
|
|
74
|
+
onReady?.(tmp);
|
|
75
|
+
});
|
|
76
|
+
afterEach(() => {
|
|
77
|
+
if (priorHome === undefined) delete process.env["HOME"];
|
|
78
|
+
else process.env["HOME"] = priorHome;
|
|
79
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Like {@link useIsolatedHome} but also chdirs into the scratch dir, so factory-time tests find the per-test config via the loader walk-up. macOS tmpdir is a symlink; canonicalized via `realpathSync` so cwd-mismatch tests don't see false-divergence.
|
|
85
|
+
*/
|
|
86
|
+
export function useScratchHome(
|
|
87
|
+
prefix: string,
|
|
88
|
+
onReady?: (tmp: string) => void,
|
|
89
|
+
): void {
|
|
90
|
+
let tmp: string;
|
|
91
|
+
let priorHome: string | undefined;
|
|
92
|
+
let priorCwd: string;
|
|
93
|
+
beforeEach(() => {
|
|
94
|
+
priorCwd = process.cwd();
|
|
95
|
+
tmp = realpathSync(mkdtempSync(join(tmpdir(), prefix)));
|
|
96
|
+
priorHome = process.env["HOME"];
|
|
97
|
+
process.env["HOME"] = tmp;
|
|
98
|
+
process.chdir(tmp);
|
|
99
|
+
onReady?.(tmp);
|
|
100
|
+
});
|
|
101
|
+
afterEach(() => {
|
|
102
|
+
process.chdir(priorCwd);
|
|
103
|
+
if (priorHome === undefined) delete process.env["HOME"];
|
|
104
|
+
else process.env["HOME"] = priorHome;
|
|
105
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Steering-config fixture writers
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Write a single-file steering config to `<dir>/.pi/steering.ts`.
|
|
115
|
+
* `body` is the full module source (must include `export default`).
|
|
116
|
+
* Used by suites whose fixtures embed regex literals or other
|
|
117
|
+
* non-JSON-friendly module shapes inline.
|
|
118
|
+
*/
|
|
119
|
+
export function writeSteeringSingleFileConfig(dir: string, body: string): void {
|
|
120
|
+
mkdirSync(join(dir, ".pi"), { recursive: true });
|
|
121
|
+
writeFileSync(join(dir, ".pi", "steering.ts"), body, "utf8");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Write a directory-form steering config to
|
|
126
|
+
* `<dir>/.pi/steering/index.ts`. `body` is the full module source
|
|
127
|
+
* (must include `export default`). Mirrors the layout the bin tests
|
|
128
|
+
* use for their isolated `@cad0p/pi-steering` invocations.
|
|
129
|
+
*/
|
|
130
|
+
export function writeSteeringDirConfig(dir: string, body: string): void {
|
|
131
|
+
const pi = join(dir, ".pi", "steering");
|
|
132
|
+
mkdirSync(pi, { recursive: true });
|
|
133
|
+
writeFileSync(join(pi, "index.ts"), body, "utf8");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Session-entry shape
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Exact shape pi's `sessionManager.getEntries()` returns for entries
|
|
142
|
+
* produced by `appendEntry`. The evaluator filters to `type: "custom"`,
|
|
143
|
+
* matches by `customType`, and reads `{ data, timestamp }` — other
|
|
144
|
+
* fields (`id`, `parentId`) exist on real entries so we mirror them
|
|
145
|
+
* here to avoid silent type drift.
|
|
146
|
+
*/
|
|
147
|
+
export interface CustomEntry {
|
|
148
|
+
readonly type: "custom";
|
|
149
|
+
readonly customType: string;
|
|
150
|
+
readonly data: unknown;
|
|
151
|
+
readonly timestamp: string;
|
|
152
|
+
readonly id: string;
|
|
153
|
+
readonly parentId: string | null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// ExtensionContext stub
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Minimal stub for pi's `ExtensionContext`. Only the fields the
|
|
162
|
+
* evaluator + observer-dispatcher read are populated; everything else
|
|
163
|
+
* throws if touched so accidental reliance on unsupported surface
|
|
164
|
+
* breaks loudly.
|
|
165
|
+
*
|
|
166
|
+
* The `entries` array mimics `sessionManager.getEntries()` output —
|
|
167
|
+
* tests that want cross-handler `findEntries` visibility pass
|
|
168
|
+
* `host.entries` (from {@link makeTrackedHost}) here so the host's
|
|
169
|
+
* `appendEntry` writes show up on subsequent reads.
|
|
170
|
+
*/
|
|
171
|
+
export function makeCtx(
|
|
172
|
+
cwd: string,
|
|
173
|
+
entries: ReadonlyArray<CustomEntry> = [],
|
|
174
|
+
): ExtensionContext {
|
|
175
|
+
return {
|
|
176
|
+
cwd,
|
|
177
|
+
sessionManager: {
|
|
178
|
+
getEntries: () => entries,
|
|
179
|
+
// Other SessionManager methods are stubbed to throw via the
|
|
180
|
+
// unknown-cast below; any accidental dependency surfaces as a
|
|
181
|
+
// clear TypeError rather than silently passing.
|
|
182
|
+
} as unknown as ExtensionContext["sessionManager"],
|
|
183
|
+
} as ExtensionContext;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// Tracked EvaluatorHost
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Tracked {@link EvaluatorHost} recording every exec / appendEntry
|
|
192
|
+
* call so tests can assert memoization + audit logging.
|
|
193
|
+
*
|
|
194
|
+
* `entries` is the backing array `makeCtx` wraps when tests want the
|
|
195
|
+
* host's `appendEntry` writes visible to a later `findEntries` read.
|
|
196
|
+
* Timestamps are monotonically-incrementing second-level ISO strings
|
|
197
|
+
* so ordering asserts stay stable inside the same millisecond.
|
|
198
|
+
*/
|
|
199
|
+
export interface TrackedHost extends EvaluatorHost {
|
|
200
|
+
readonly execCalls: Array<{ cmd: string; args: string[]; cwd: string }>;
|
|
201
|
+
readonly appended: Array<{ type: string; data: unknown }>;
|
|
202
|
+
readonly entries: CustomEntry[];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Build a {@link TrackedHost}. Optional `exec` override lets evaluator
|
|
207
|
+
* tests count real invocations against the cache (the default exec
|
|
208
|
+
* returns `{ stdout: "", stderr: "", code: 0, killed: false }`).
|
|
209
|
+
*/
|
|
210
|
+
export function makeTrackedHost(options?: {
|
|
211
|
+
exec?: (cmd: string, args: string[], cwd: string) => Promise<PiExecResult>;
|
|
212
|
+
}): TrackedHost {
|
|
213
|
+
const execCalls: TrackedHost["execCalls"] = [];
|
|
214
|
+
const appended: TrackedHost["appended"] = [];
|
|
215
|
+
const entries: CustomEntry[] = [];
|
|
216
|
+
let idCounter = 0;
|
|
217
|
+
return {
|
|
218
|
+
execCalls,
|
|
219
|
+
appended,
|
|
220
|
+
entries,
|
|
221
|
+
exec: async (cmd, args, opts) => {
|
|
222
|
+
const cwd = opts?.cwd ?? "/";
|
|
223
|
+
execCalls.push({ cmd, args: [...args], cwd });
|
|
224
|
+
if (options?.exec) {
|
|
225
|
+
return options.exec(cmd, args, cwd);
|
|
226
|
+
}
|
|
227
|
+
return { stdout: "", stderr: "", code: 0, killed: false };
|
|
228
|
+
},
|
|
229
|
+
appendEntry: (type, data) => {
|
|
230
|
+
appended.push({ type, data });
|
|
231
|
+
entries.push({
|
|
232
|
+
type: "custom",
|
|
233
|
+
customType: type,
|
|
234
|
+
data,
|
|
235
|
+
timestamp: new Date(
|
|
236
|
+
Date.UTC(2026, 0, 1, 0, 0, idCounter++),
|
|
237
|
+
).toISOString(),
|
|
238
|
+
id: `entry-${idCounter}`,
|
|
239
|
+
parentId: null,
|
|
240
|
+
});
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
}
|