@cad0p/pi-steering 0.1.0 → 0.2.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.
Files changed (69) hide show
  1. package/CHANGELOG.md +27 -4
  2. package/README.md +20 -6
  3. package/dist/__test-helpers__.d.ts +5 -4
  4. package/dist/__test-helpers__.d.ts.map +1 -1
  5. package/dist/__test-helpers__.js +5 -4
  6. package/dist/__test-helpers__.js.map +1 -1
  7. package/dist/bin/pi-steering.d.ts.map +1 -1
  8. package/dist/bin/pi-steering.js +22 -16
  9. package/dist/bin/pi-steering.js.map +1 -1
  10. package/dist/evaluator.d.ts +0 -41
  11. package/dist/evaluator.d.ts.map +1 -1
  12. package/dist/evaluator.js +42 -1
  13. package/dist/evaluator.js.map +1 -1
  14. package/dist/index.js +1 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/internal/ref-text.d.ts.map +1 -1
  17. package/dist/internal/ref-text.js +1 -1
  18. package/dist/internal/ref-text.js.map +1 -1
  19. package/dist/internal/session-runtime.d.ts.map +1 -1
  20. package/dist/internal/session-runtime.js +3 -2
  21. package/dist/internal/session-runtime.js.map +1 -1
  22. package/dist/loader.d.ts +23 -18
  23. package/dist/loader.d.ts.map +1 -1
  24. package/dist/loader.js +91 -87
  25. package/dist/loader.js.map +1 -1
  26. package/dist/plugins/git/cwd-extensions.d.ts.map +1 -1
  27. package/dist/plugins/git/cwd-extensions.js.map +1 -1
  28. package/dist/schema.d.ts +4 -4
  29. package/dist/testing/index.d.ts +4 -5
  30. package/dist/testing/index.d.ts.map +1 -1
  31. package/dist/testing/index.js +4 -4
  32. package/dist/testing/index.js.map +1 -1
  33. package/examples/README.md +3 -3
  34. package/examples/combined-git-discipline/README.md +2 -2
  35. package/examples/combined-git-discipline/node_modules/.bin/pi-steering +16 -4
  36. package/examples/combined-git-discipline/package.json +16 -16
  37. package/examples/draft-prs-only/README.md +2 -2
  38. package/examples/draft-prs-only/node_modules/.bin/pi-steering +16 -4
  39. package/examples/draft-prs-only/package.json +16 -16
  40. package/examples/dynamic-reason-runtime-cwd/node_modules/.bin/pi-steering +16 -4
  41. package/examples/dynamic-reason-runtime-cwd/package.json +16 -16
  42. package/examples/dynamic-reason-runtime-cwd/steering.ts +5 -1
  43. package/examples/force-push-strict/README.md +1 -1
  44. package/examples/force-push-strict/node_modules/.bin/pi-steering +16 -4
  45. package/examples/force-push-strict/package.json +16 -16
  46. package/examples/no-amend/README.md +2 -2
  47. package/examples/no-amend/node_modules/.bin/pi-steering +16 -4
  48. package/examples/no-amend/package.json +16 -16
  49. package/examples/work-item-plugin/node_modules/.bin/pi-steering +16 -4
  50. package/examples/work-item-plugin/package.json +17 -17
  51. package/examples/work-item-plugin/src/observers/npm-test-tracker.ts +5 -1
  52. package/examples/work-item-plugin/src/observers/retest-required-tracker.ts +5 -1
  53. package/examples/work-item-plugin/src/rules/commit-requires-work-item.test.ts +5 -1
  54. package/package.json +2 -2
  55. package/src/__test-helpers__.ts +5 -4
  56. package/src/bin/pi-steering.test.ts +14 -4
  57. package/src/bin/pi-steering.ts +22 -16
  58. package/src/evaluator.ts +6 -6
  59. package/src/factory-time-load.test.ts +24 -18
  60. package/src/index.test.ts +4 -4
  61. package/src/index.ts +1 -1
  62. package/src/internal/ref-text.ts +5 -1
  63. package/src/internal/session-runtime.test.ts +2 -2
  64. package/src/internal/session-runtime.ts +3 -2
  65. package/src/loader.test.ts +226 -74
  66. package/src/loader.ts +99 -82
  67. package/src/plugins/git/cwd-extensions.ts +1 -1
  68. package/src/schema.ts +4 -4
  69. package/src/testing/index.ts +7 -7
@@ -25,7 +25,11 @@
25
25
  * downstream typos become compile errors.
26
26
  */
27
27
 
28
- import type { Observer, ObserverContext, PredicateContext } from "@cad0p/pi-steering";
28
+ import type {
29
+ Observer,
30
+ ObserverContext,
31
+ PredicateContext,
32
+ } from "@cad0p/pi-steering";
29
33
 
30
34
  /**
31
35
  * Session-entry type written when `npm test` succeeds. Rules gate via
@@ -28,7 +28,11 @@
28
28
  * from downstream rules.
29
29
  */
30
30
 
31
- import type { Observer, ObserverContext, PredicateContext } from "@cad0p/pi-steering";
31
+ import type {
32
+ Observer,
33
+ ObserverContext,
34
+ PredicateContext,
35
+ } from "@cad0p/pi-steering";
32
36
 
33
37
  /**
34
38
  * Session-entry event written when `git pull` succeeds. Rules that
@@ -12,7 +12,11 @@
12
12
 
13
13
  import { describe, it } from "node:test";
14
14
  import type { Plugin } from "@cad0p/pi-steering";
15
- import { expectAllows, expectBlocks, loadHarness } from "@cad0p/pi-steering/testing";
15
+ import {
16
+ expectAllows,
17
+ expectBlocks,
18
+ loadHarness,
19
+ } from "@cad0p/pi-steering/testing";
16
20
  import { workItemFormat } from "../predicates/work-item-format.ts";
17
21
  import { commitRequiresWorkItem } from "./commit-requires-work-item.ts";
18
22
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@cad0p/pi-steering",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
- "description": "AST-backed steering hooks for pi \u2014 deterministic tool-call guardrails with command-level effective-cwd scoping.",
5
+ "description": "AST-backed steering hooks for pi deterministic tool-call guardrails with command-level effective-cwd scoping.",
6
6
  "keywords": [
7
7
  "pi-package",
8
8
  "extension",
@@ -53,9 +53,10 @@ import type { EvaluatorHost } from "./evaluator-internals/context.ts";
53
53
  * - restore `process.env["HOME"]` and recursively remove the temp
54
54
  * dir on teardown.
55
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.
56
+ * Used by every test surface that exercises the loader's two-layer
57
+ * discovery (`index.test.ts`, `loader.test.ts`,
58
+ * `internal/session-runtime.test.ts`) so the per-file scratch-HOME
59
+ * boilerplate stays in one place.
59
60
  *
60
61
  * The temp dir path is exposed via the optional `onReady` callback,
61
62
  * fired inside `beforeEach`; tests typically stash it in a
@@ -81,7 +82,7 @@ export function useIsolatedHome(
81
82
  }
82
83
 
83
84
  /**
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
+ * Like {@link useIsolatedHome} but also chdirs into the scratch dir, so factory-time tests find the per-test config via the loader's project layer. macOS tmpdir is a symlink; canonicalized via `realpathSync` so cwd-mismatch tests don't see false-divergence.
85
86
  */
86
87
  export function useScratchHome(
87
88
  prefix: string,
@@ -50,8 +50,8 @@ interface RunResult {
50
50
  * non-zero exit codes — the caller asserts on `code`.
51
51
  *
52
52
  * Accepts an optional `cwd` so tests for the `list` subcommand can
53
- * point the walk-up loader at a scratch directory without polluting
54
- * the project.
53
+ * point the loader's project layer at a scratch directory and isolate
54
+ * HOME so no real global config leaks, without polluting the project.
55
55
  */
56
56
  function runCli(...args: string[]): Promise<RunResult>;
57
57
  function runCli(opts: { cwd?: string }, ...args: string[]): Promise<RunResult>;
@@ -67,6 +67,12 @@ function runCli(
67
67
  } else {
68
68
  args = first === undefined ? [...rest] : [first, ...rest];
69
69
  }
70
+ // The child CLI reads the global layer from `$HOME`; without
71
+ // isolation the developer's real `~/.pi/agent/steering/` would
72
+ // leak into the tests. `PI_CODING_AGENT_DIR` must be removed (an
73
+ // empty string still overrides the default agent dir).
74
+ const env: NodeJS.ProcessEnv = { ...process.env, HOME: scratch };
75
+ delete env.PI_CODING_AGENT_DIR;
70
76
  return new Promise((resolvePromise, rejectPromise) => {
71
77
  const child = spawn(
72
78
  process.execPath,
@@ -74,6 +80,7 @@ function runCli(
74
80
  {
75
81
  stdio: ["ignore", "pipe", "pipe"],
76
82
  ...(cwd !== undefined ? { cwd } : {}),
83
+ env,
77
84
  },
78
85
  );
79
86
  let stdout = "";
@@ -229,7 +236,10 @@ describe("pi-steering import-json: conversion", () => {
229
236
  const r = await runCli("import-json", path);
230
237
  assert.equal(r.code, 0);
231
238
  assert.equal(r.stderr.trim(), "");
232
- assert.match(r.stdout, /import \{ defineConfig \} from "@cad0p\/pi-steering"/);
239
+ assert.match(
240
+ r.stdout,
241
+ /import \{ defineConfig \} from "@cad0p\/pi-steering"/,
242
+ );
233
243
  assert.match(r.stdout, /export default defineConfig\(/);
234
244
  assert.match(r.stdout, /"no-amend"/);
235
245
  assert.match(r.stdout, /"Don't rewrite history\."/);
@@ -345,7 +355,7 @@ describe("pi-steering list", () => {
345
355
  assert.match(r.stdout, /Resolved config: 1 plugin, 2 rules, 0 observers\./);
346
356
  assert.match(r.stdout, /git\s+\[pi-steering\/plugins\/git\]/);
347
357
  assert.match(r.stdout, /no-main-commit\s+bash\s+when: branch/);
348
- assert.match(r.stdout, /User \(\.pi\/steering\/index\.ts\):/);
358
+ assert.match(r.stdout, /User \(project \+ global\):/);
349
359
  assert.match(r.stdout, /my-rule\s+bash/);
350
360
  assert.match(r.stdout, /Disabled rules: some-disabled-rule/);
351
361
  });
@@ -9,7 +9,8 @@
9
9
  * Convert a v1 JSON config to the v2 TS config shape.
10
10
  *
11
11
  * pi-steering list [--format=text|json]
12
- * Resolve the walk-up config from the CWD and print the
12
+ * Resolve the two-layer config (project `<cwd>/.pi/steering/` +
13
+ * global `<agentDir>/steering/`) from the CWD and print the
13
14
  * effective plugins / rules / observers / disables.
14
15
  *
15
16
  * Exit codes:
@@ -75,10 +76,12 @@ SUBCOMMANDS
75
76
  the JSON-to-TS conversion surface and rejected features.
76
77
 
77
78
  list [--format=text|json]
78
- Load the effective config for the current directory (walk-up
79
- from cwd) and print the resolved plugins, rules, and observers,
80
- grouped by source. Useful for answering "which rules are
81
- active here?" without reading the config files by hand.
79
+ Load the effective config for the current directory (project
80
+ layer at <cwd>/.pi/steering/ + global layer at
81
+ <agentDir>/steering/) and print the resolved plugins, rules,
82
+ and observers, grouped by source. Useful for answering "which
83
+ rules are active here?" without reading the config files by
84
+ hand.
82
85
 
83
86
  OPTIONS
84
87
  -h, --help Show this help.
@@ -214,8 +217,9 @@ async function runList(args: string[]): Promise<number> {
214
217
  }
215
218
  }
216
219
 
217
- // Walk up from cwd and merge. CLI deliberately omits
218
- // DEFAULT_PLUGINS / DEFAULT_RULES; runtime injects them.
220
+ // Load project layer (cwd) + global layer (agent dir) and merge.
221
+ // CLI deliberately omits DEFAULT_PLUGINS / DEFAULT_RULES; runtime
222
+ // injects them.
219
223
  let layers: readonly SteeringConfig[];
220
224
  let loaderDiagnostics: readonly SteeringDiagnostic[] = [];
221
225
  try {
@@ -280,10 +284,11 @@ async function runList(args: string[]): Promise<number> {
280
284
  /**
281
285
  * CLI variant of the merge pipeline. Redirects `console.info`
282
286
  * breadcrumbs (disabled-plugin / disabled-rule / dropped-observer)
283
- * onto stderr so stdout stays clean for `--format=json`. After
284
- * {@link runMergerPipeline}, mirrors {@link buildSessionRuntime}'s
285
- * `disabledRules` filter + `finalizePluginState` so `pi-steering
286
- * list` reports the same observer-drop set production sees.
287
+ * onto stderr so stdout stays clean for `--format=json`. Loads the
288
+ * project + global layers and merges them inner-first (project wins),
289
+ * then mirrors {@link buildSessionRuntime}'s `disabledRules` filter +
290
+ * `finalizePluginState` so `pi-steering list` reports the same
291
+ * observer-drop set production sees.
287
292
  */
288
293
  function runCliMergeWithInfoCapture(layers: readonly SteeringConfig[]): {
289
294
  config: SteeringConfig;
@@ -333,9 +338,10 @@ function printListHelp(): void {
333
338
  USAGE
334
339
  pi-steering list [--format=text|json]
335
340
 
336
- Walks up from the current directory looking for .pi/steering/index.ts
337
- (or .pi/steering.ts) at each ancestor. Merges the layers inner-first
338
- and prints the effective plugins, rules, and observers.
341
+ Loads the project layer (<cwd>/.pi/steering/) and the global layer
342
+ (~/.pi/agent/steering/, or $PI_CODING_AGENT_DIR/steering) and prints
343
+ the effective plugins, rules, and observers. Project layer wins on
344
+ rule-name collision.
339
345
 
340
346
  FLAGS
341
347
  --format=text (default) human-readable grouped output
@@ -443,7 +449,7 @@ function observerJSON(o: Observer): unknown {
443
449
  * no-main-commit bash when: branch
444
450
  * ...
445
451
  *
446
- * User (.pi/steering/index.ts):
452
+ * User (project + global):
447
453
  * (none)
448
454
  *
449
455
  * Disabled: (none)
@@ -484,7 +490,7 @@ function renderListText(config: SteeringConfig): string {
484
490
  }
485
491
 
486
492
  // User block.
487
- lines.push("User (.pi/steering/index.ts):");
493
+ lines.push("User (project + global):");
488
494
  if (userRules.length === 0 && userObservers.length === 0) {
489
495
  lines.push(" (none)");
490
496
  } else {
package/src/evaluator.ts CHANGED
@@ -43,12 +43,6 @@
43
43
  * `evaluateCandidate` stays tool-agnostic.
44
44
  */
45
45
 
46
- import type {
47
- ExtensionContext,
48
- ToolCallEvent,
49
- ToolCallEventResult,
50
- } from "@earendil-works/pi-coding-agent";
51
- import { isToolCallEventType } from "@earendil-works/pi-coding-agent";
52
46
  import {
53
47
  type CommandRef,
54
48
  cwdTracker,
@@ -63,6 +57,12 @@ import {
63
57
  type Word,
64
58
  walk,
65
59
  } from "@cad0p/unbash-walker";
60
+ import type {
61
+ ExtensionContext,
62
+ ToolCallEvent,
63
+ ToolCallEventResult,
64
+ } from "@earendil-works/pi-coding-agent";
65
+ import { isToolCallEventType } from "@earendil-works/pi-coding-agent";
66
66
  import {
67
67
  createAppendEntry,
68
68
  createExecCache,
@@ -26,7 +26,7 @@
26
26
  * - aggregated render snapshot pins the multi-line format.
27
27
  *
28
28
  * The bridge factory is async; tests `chdir` into a fresh scratch
29
- * `$HOME` so the loader walk-up reads the per-test config.
29
+ * `$HOME` so the loader's global layer reads the per-test config.
30
30
  */
31
31
 
32
32
  import assert from "node:assert/strict";
@@ -224,24 +224,27 @@ describe("register(): factory throws on diagnostics", () => {
224
224
  });
225
225
 
226
226
  it("throws on plugin-name-collision (warning-class, failOnWarnings default)", async () => {
227
- // Two layers ship the same plugin name. The collision is
228
- // warning-class but escalates under the default
227
+ // Project and global layers ship the same plugin name. The
228
+ // collision is warning-class but escalates under the default
229
229
  // `failOnWarnings: true`.
230
- mkdirSync(join(tmpHome, "inner"), { recursive: true });
230
+ const inner = join(tmpHome, "inner");
231
+ mkdirSync(inner, { recursive: true });
231
232
  writeSteeringSingleFileConfig(
232
- tmpHome,
233
+ inner,
233
234
  `export default {
234
- disableDefaults: true,
235
235
  plugins: [{ name: "shared" }],
236
236
  };`,
237
237
  );
238
- writeSteeringSingleFileConfig(
239
- join(tmpHome, "inner"),
238
+ mkdirSync(join(tmpHome, ".pi", "agent"), { recursive: true });
239
+ writeFileSync(
240
+ join(tmpHome, ".pi", "agent", "steering.ts"),
240
241
  `export default {
242
+ disableDefaults: true,
241
243
  plugins: [{ name: "shared" }],
242
244
  };`,
245
+ "utf8",
243
246
  );
244
- process.chdir(join(tmpHome, "inner"));
247
+ process.chdir(inner);
245
248
  await expectRegisterThrow([/\[warning\]/, /plugin "shared"/]);
246
249
  });
247
250
 
@@ -385,26 +388,29 @@ describe("register(): factory does NOT throw", () => {
385
388
  });
386
389
 
387
390
  it("disabledPlugins resolves plugin-name-collision before the check runs", async () => {
388
- // The outer layer's `disabledPlugins: ["shared"]` removes
391
+ // The global layer's `disabledPlugins: ["shared"]` removes
389
392
  // the would-be collision before cross-layer detection,
390
393
  // matching the disable-then-detect ordering. No throw, no
391
394
  // warn.
392
- mkdirSync(join(tmpHome, "inner"), { recursive: true });
395
+ const inner = join(tmpHome, "inner");
396
+ mkdirSync(inner, { recursive: true });
393
397
  writeSteeringSingleFileConfig(
394
- tmpHome,
398
+ inner,
395
399
  `export default {
396
- disableDefaults: true,
397
- disabledPlugins: ["shared"],
398
400
  plugins: [{ name: "shared" }],
399
401
  };`,
400
402
  );
401
- writeSteeringSingleFileConfig(
402
- join(tmpHome, "inner"),
403
+ mkdirSync(join(tmpHome, ".pi", "agent"), { recursive: true });
404
+ writeFileSync(
405
+ join(tmpHome, ".pi", "agent", "steering.ts"),
403
406
  `export default {
407
+ disableDefaults: true,
408
+ disabledPlugins: ["shared"],
404
409
  plugins: [{ name: "shared" }],
405
410
  };`,
411
+ "utf8",
406
412
  );
407
- process.chdir(join(tmpHome, "inner"));
413
+ process.chdir(inner);
408
414
  const mock = makeMockPi();
409
415
  await register(mock.api as ExtensionAPI);
410
416
  const collisionWarn = capturedWarns.find((m) => /plugin "shared"/i.test(m));
@@ -448,7 +454,7 @@ describe("register(): cwd-mismatch session_start warn", () => {
448
454
 
449
455
  it("emits console.warn when ctx.cwd !== launchCwd; engine continues evaluating", async () => {
450
456
  // A user-defined rule lives in the launch-cwd config (tmpHome) but
451
- // not in foreignCwd's walk-up; if it still fires after the
457
+ // not in foreignCwd's project layer; if it still fires after the
452
458
  // cwd-mismatch warn, launch-cwd config remained in force.
453
459
  writeSteeringSingleFileConfig(
454
460
  tmpHome,
package/src/index.test.ts CHANGED
@@ -14,8 +14,8 @@
14
14
  * - the audit-log side effect for accepted overrides,
15
15
  * - the observer-dispatcher side effect on matching tool_result
16
16
  * events,
17
- * - the walk-up TS-config loader: {@link buildSessionRuntime} reads
18
- * `.pi/steering.ts` from an isolated `$HOME`.
17
+ * - the two-layer TS-config loader: {@link buildSessionRuntime}
18
+ * reads `.pi/steering.ts` from an isolated `$HOME`.
19
19
  *
20
20
  * The bridge factory's lifecycle wiring + config-loading glue isn't
21
21
  * covered by the unit suites; this file is the only end-to-end check.
@@ -180,8 +180,8 @@ let tmpHome: string;
180
180
  * Bind a fresh `$HOME` per test AND chdir into it via the shared
181
181
  * {@link useScratchHome} helper. The bridge factory eagerly loads
182
182
  * from `process.cwd()` at register time, so tests must launch from
183
- * the scratch home for the loader walk-up to find the per-test
184
- * config.
183
+ * the scratch home for the loader's project + global layers to find
184
+ * the per-test config.
185
185
  */
186
186
  function useRegisterScratchHome(): void {
187
187
  useScratchHome("pi-steering-register-", (t) => {
package/src/index.ts CHANGED
@@ -141,7 +141,7 @@ export { AGENT_LOOP_INDEX_KEY } from "./evaluator-internals/context.ts";
141
141
  // `if (ctx.walkerState?.cwd === "unknown") return "unknown";`,
142
142
  // then the engine projects via `onUnknown:` policy).
143
143
  export { walkerUnknownCwdReason } from "./helpers/walker-unknown-cwd-reason.ts";
144
- // Loader — walk-up config discovery + merge.
144
+ // Loader — two-layer config discovery + merge.
145
145
  export { buildConfig, loadConfigs, loadSteeringConfig } from "./loader.ts";
146
146
  // Schema types — the public authoring surface.
147
147
  export type {
@@ -9,7 +9,11 @@
9
9
  * @internal — not part of the public pi-steering surface.
10
10
  */
11
11
 
12
- import { type CommandRef, getBasename, getCommandArgs } from "@cad0p/unbash-walker";
12
+ import {
13
+ type CommandRef,
14
+ getBasename,
15
+ getCommandArgs,
16
+ } from "@cad0p/unbash-walker";
13
17
 
14
18
  /** Render a ref as `"{basename} {args joined by space}"`, trimmed. */
15
19
  export function refToText(ref: CommandRef): string {
@@ -13,8 +13,8 @@
13
13
  * - the aggregated error message follows the rule-based spec
14
14
  * (header + bullets, errors first, optional path prefix).
15
15
  *
16
- * Uses a tmp `$HOME` so the walk-up loader's `$HOME` ceiling is
17
- * scoped to the test directory.
16
+ * Uses a tmp `$HOME` so the loader's global layer is scoped to the
17
+ * test directory.
18
18
  */
19
19
 
20
20
  import assert from "node:assert/strict";
@@ -5,8 +5,9 @@
5
5
  * Internal module — not part of the package's public API.
6
6
  *
7
7
  * This module holds the wiring that the bridge factory in `index.ts`
8
- * uses to spin up an evaluator + observer dispatcher from a walk-up
9
- * steering config. It is intentionally NOT re-exported from
8
+ * uses to spin up an evaluator + observer dispatcher from the
9
+ * two-layer config (project layer at `cwd`, global layer at the
10
+ * agent dir). It is intentionally NOT re-exported from
10
11
  * `index.ts` or any other public entry point; consumers building
11
12
  * their own extensions should go through `loadHarness` (subpath
12
13
  * `@cad0p/pi-steering/testing`) or call `buildEvaluator` /