@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
@@ -2,28 +2,30 @@
2
2
  // Part of pi-steering.
3
3
 
4
4
  /**
5
- * Walk-up discovery + merge tests for {@link loadConfigs},
5
+ * Two-layer discovery + merge tests for {@link loadConfigs},
6
6
  * {@link buildConfig}, and {@link loadSteeringConfig}.
7
7
  *
8
8
  * Uses the same scratch-HOME + `mkdtempSync` pattern as the v1 JSON
9
9
  * loader tests (`../loader.test.ts`) to keep global config leakage
10
- * out of the test run. Fixtures are written fresh per test so runs
11
- * are reproducible without repo-committed scratch files.
10
+ * out of the test run the isolated `$HOME` also points the global
11
+ * layer at a scratch `<tmp>/.pi/agent/steering/`. Fixtures are
12
+ * written fresh per test so runs are reproducible without
13
+ * repo-committed scratch files.
12
14
  */
13
15
 
14
16
  import assert from "node:assert/strict";
15
17
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
16
- import { tmpdir } from "node:os";
18
+ import { homedir, tmpdir } from "node:os";
17
19
  import { join } from "node:path";
18
20
  import { afterEach, beforeEach, describe, it } from "node:test";
19
21
  import { useIsolatedHome } from "./__test-helpers__.ts";
20
22
  import {
21
- ancestorChain,
22
23
  buildConfig,
23
24
  configCandidates,
24
25
  findConfigFile,
25
26
  loadConfigs,
26
27
  loadSteeringConfig,
28
+ resolveAgentDir,
27
29
  } from "./loader.ts";
28
30
  import type { Plugin, SteeringConfig } from "./schema.ts";
29
31
 
@@ -57,63 +59,46 @@ describe("loader: configCandidates", () => {
57
59
  assert.equal(a, "/tmp/x/.pi/steering/index.ts");
58
60
  assert.equal(b, "/tmp/x/.pi/steering.ts");
59
61
  });
60
- });
61
62
 
62
- describe("loader: ancestorChain", () => {
63
- it("returns innermost first", () => {
64
- const prior = process.env["HOME"];
65
- process.env["HOME"] = "/home/user";
66
- try {
67
- const chain = ancestorChain("/home/user/projects/foo/bar");
68
- assert.deepEqual(chain, [
69
- "/home/user/projects/foo/bar",
70
- "/home/user/projects/foo",
71
- "/home/user/projects",
72
- "/home/user",
73
- ]);
74
- } finally {
75
- if (prior === undefined) delete process.env["HOME"];
76
- else process.env["HOME"] = prior;
77
- }
63
+ it("honors a custom slot", () => {
64
+ const [a, b] = configCandidates("/tmp/x", "steering");
65
+ assert.equal(a, "/tmp/x/steering/index.ts");
66
+ assert.equal(b, "/tmp/x/steering.ts");
78
67
  });
68
+ });
79
69
 
80
- it("stops at filesystem root if $HOME is unset", () => {
81
- const prior = process.env["HOME"];
82
- delete process.env["HOME"];
70
+ describe("loader: resolveAgentDir", () => {
71
+ useIsolatedHome("pi-steering-v2-agentdir-");
72
+
73
+ it("falls back to ~/.pi/agent when PI_CODING_AGENT_DIR is unset", () => {
74
+ const prior = process.env["PI_CODING_AGENT_DIR"];
75
+ delete process.env["PI_CODING_AGENT_DIR"];
83
76
  try {
84
- const chain = ancestorChain("/a/b");
85
- assert.deepEqual(chain, ["/a/b", "/a", "/"]);
77
+ assert.equal(resolveAgentDir(), join(homedir(), ".pi", "agent"));
86
78
  } finally {
87
- if (prior !== undefined) process.env["HOME"] = prior;
79
+ if (prior !== undefined) process.env["PI_CODING_AGENT_DIR"] = prior;
88
80
  }
89
81
  });
90
82
 
91
- it("cwd === $HOME returns [$HOME] only", () => {
92
- const prior = process.env["HOME"];
93
- const home = mkdtempSync(join(tmpdir(), "pi-steering-v2-chain-home-"));
94
- process.env["HOME"] = home;
83
+ it("treats an empty-string PI_CODING_AGENT_DIR as unset", () => {
84
+ const prior = process.env["PI_CODING_AGENT_DIR"];
85
+ process.env["PI_CODING_AGENT_DIR"] = "";
95
86
  try {
96
- const chain = ancestorChain(home);
97
- assert.deepEqual(chain, [home]);
87
+ assert.equal(resolveAgentDir(), join(homedir(), ".pi", "agent"));
98
88
  } finally {
99
- if (prior === undefined) delete process.env["HOME"];
100
- else process.env["HOME"] = prior;
101
- rmSync(home, { recursive: true, force: true });
89
+ if (prior === undefined) delete process.env["PI_CODING_AGENT_DIR"];
90
+ else process.env["PI_CODING_AGENT_DIR"] = prior;
102
91
  }
103
92
  });
104
93
 
105
- it("cwd OUTSIDE $HOME tree walks to filesystem root", () => {
106
- const prior = process.env["HOME"];
107
- process.env["HOME"] = "/home/user";
94
+ it("expands a bare ~ PI_CODING_AGENT_DIR to homedir", () => {
95
+ const prior = process.env["PI_CODING_AGENT_DIR"];
96
+ process.env["PI_CODING_AGENT_DIR"] = "~";
108
97
  try {
109
- // cwd is in /other/path — no shared prefix with $HOME, so the
110
- // walk should run to filesystem root rather than terminating
111
- // at the (never-reached) $HOME sentinel.
112
- const chain = ancestorChain("/other/path");
113
- assert.deepEqual(chain, ["/other/path", "/other", "/"]);
98
+ assert.equal(resolveAgentDir(), homedir());
114
99
  } finally {
115
- if (prior === undefined) delete process.env["HOME"];
116
- else process.env["HOME"] = prior;
100
+ if (prior === undefined) delete process.env["PI_CODING_AGENT_DIR"];
101
+ else process.env["PI_CODING_AGENT_DIR"] = prior;
117
102
  }
118
103
  });
119
104
  });
@@ -165,10 +150,23 @@ describe("loader: findConfigFile", () => {
165
150
  /both .pi\/steering.ts and .pi\/steering\/index.ts/,
166
151
  );
167
152
  });
153
+
154
+ it("finds the global slot (steering) under the agent dir", () => {
155
+ writeConfig(join(tmp, "steering.ts"), configModule("{}"));
156
+ writeConfig(join(tmp, "steering", "index.ts"), configModule("{}"));
157
+ const { file, diagnostic } = findConfigFile(tmp, "steering");
158
+ assert.equal(file, join(tmp, "steering", "index.ts"));
159
+ assert.ok(
160
+ diagnostic,
161
+ "expected a coexistence diagnostic for the global slot",
162
+ );
163
+ assert.match(diagnostic.message, /steering\.ts and steering\/index\.ts/);
164
+ });
168
165
  });
169
166
 
170
167
  // ---------------------------------------------------------------------------
171
- // loadConfigs — walk-up, stray-file diagnostic, bad layer handling
168
+ // loadConfigs — two-layer discovery, stray-file diagnostic, bad layer
169
+ // handling
172
170
  // ---------------------------------------------------------------------------
173
171
 
174
172
  describe("loader: loadConfigs", () => {
@@ -185,24 +183,131 @@ describe("loader: loadConfigs", () => {
185
183
  assert.deepEqual(diagnostics, []);
186
184
  });
187
185
 
188
- it("collects ancestor configs inner-first", async () => {
189
- const outer = join(tmp, "a");
190
- const inner = join(tmp, "a", "b");
191
- mkdirSync(inner, { recursive: true });
186
+ it("loads the project layer first, then the global layer", async () => {
187
+ const proj = join(tmp, "proj");
188
+ mkdirSync(proj, { recursive: true });
192
189
  writeConfig(
193
- join(outer, ".pi", "steering.ts"),
194
- configModule("{ disabledRules: ['outer-only'] }"),
190
+ join(proj, ".pi", "steering.ts"),
191
+ configModule("{ disabledRules: ['project'] }"),
195
192
  );
196
193
  writeConfig(
197
- join(inner, ".pi", "steering.ts"),
198
- configModule("{ disabledRules: ['inner-only'] }"),
194
+ join(tmp, ".pi", "agent", "steering", "index.ts"),
195
+ configModule("{ disabledRules: ['global'] }"),
199
196
  );
200
- const { layers } = await loadConfigs(inner);
201
- // Inner (b) → outer (a) order.
197
+ const { layers } = await loadConfigs(proj);
198
+ // Project (inner) → global (outer) order.
202
199
  assert.deepEqual(
203
200
  layers.map((l) => l.disabledRules?.[0]),
204
- ["inner-only", "outer-only"],
201
+ ["project", "global"],
202
+ );
203
+ });
204
+
205
+ it("does not collect intermediate ancestor layers (walk-up removed)", async () => {
206
+ // Pins the breaking change: a config in an ancestor directory
207
+ // between cwd and HOME is no longer discovered — only the cwd
208
+ // project layer and the agent-dir global layer load.
209
+ const cwd = join(tmp, "a", "b");
210
+ mkdirSync(cwd, { recursive: true });
211
+ writeConfig(
212
+ join(tmp, "a", ".pi", "steering.ts"),
213
+ configModule("{ disabledRules: ['ancestor'] }"),
214
+ );
215
+ const { layers, diagnostics } = await loadConfigs(cwd);
216
+ assert.deepEqual(layers, []);
217
+ assert.deepEqual(diagnostics, []);
218
+ });
219
+
220
+ it("loads legacy ~/.pi/steering/ only when cwd is home", async () => {
221
+ // The old global location still works in exactly one situation:
222
+ // launching from $HOME itself, where the project layer
223
+ // `<cwd>/.pi/steering/` IS the legacy `~/.pi/steering/` path.
224
+ const sub = join(tmp, "sub");
225
+ mkdirSync(sub, { recursive: true });
226
+ writeConfig(
227
+ join(tmp, ".pi", "steering.ts"),
228
+ configModule("{ disabledRules: ['legacy'] }"),
229
+ );
230
+ writeConfig(
231
+ join(tmp, ".pi", "agent", "steering", "index.ts"),
232
+ configModule("{ disabledRules: ['global'] }"),
233
+ );
234
+ const fromSub = await loadConfigs(sub);
235
+ assert.deepEqual(
236
+ fromSub.layers.map((l) => l.disabledRules?.[0]),
237
+ ["global"],
238
+ "legacy ~/.pi/steering/ must NOT load below home",
239
+ );
240
+ const fromHome = await loadConfigs(tmp);
241
+ assert.deepEqual(
242
+ fromHome.layers.map((l) => l.disabledRules?.[0]),
243
+ ["legacy", "global"],
244
+ "at cwd === home the project layer is the legacy path",
245
+ );
246
+ });
247
+
248
+ it("honors PI_CODING_AGENT_DIR for the global layer", async () => {
249
+ const prior = process.env["PI_CODING_AGENT_DIR"];
250
+ process.env["PI_CODING_AGENT_DIR"] = join(tmp, "custom-agent");
251
+ try {
252
+ const proj = join(tmp, "proj");
253
+ mkdirSync(proj, { recursive: true });
254
+ writeConfig(
255
+ join(tmp, "custom-agent", "steering", "index.ts"),
256
+ configModule("{ disabledRules: ['custom'] }"),
257
+ );
258
+ const { layers } = await loadConfigs(proj);
259
+ assert.deepEqual(
260
+ layers.map((l) => l.disabledRules?.[0]),
261
+ ["custom"],
262
+ );
263
+ } finally {
264
+ if (prior === undefined) delete process.env["PI_CODING_AGENT_DIR"];
265
+ else process.env["PI_CODING_AGENT_DIR"] = prior;
266
+ }
267
+ });
268
+
269
+ it("tilde-expands a ~/… PI_CODING_AGENT_DIR under HOME", async () => {
270
+ const prior = process.env["PI_CODING_AGENT_DIR"];
271
+ process.env["PI_CODING_AGENT_DIR"] = "~/my-agent";
272
+ try {
273
+ const proj = join(tmp, "proj");
274
+ mkdirSync(proj, { recursive: true });
275
+ writeConfig(
276
+ join(tmp, "my-agent", "steering", "index.ts"),
277
+ configModule("{ disabledRules: ['tilde'] }"),
278
+ );
279
+ const { layers } = await loadConfigs(proj);
280
+ assert.deepEqual(
281
+ layers.map((l) => l.disabledRules?.[0]),
282
+ ["tilde"],
283
+ );
284
+ } finally {
285
+ if (prior === undefined) delete process.env["PI_CODING_AGENT_DIR"];
286
+ else process.env["PI_CODING_AGENT_DIR"] = prior;
287
+ }
288
+ });
289
+
290
+ it("records a layer-import-failed diagnostic for the GLOBAL layer under the agent dir", async () => {
291
+ // Mirrors the project-layer import-failed pin: the shared
292
+ // loadLayer path must surface the same diagnostic shape when the
293
+ // failing module lives at `<agentDir>/steering.ts` (default agent
294
+ // dir under the isolated HOME).
295
+ const proj = join(tmp, "proj");
296
+ mkdirSync(proj, { recursive: true });
297
+ writeConfig(
298
+ join(tmp, ".pi", "agent", "steering.ts"),
299
+ "export default { rules: {{ not valid ts }} };",
300
+ );
301
+ const { layers, diagnostics } = await loadConfigs(proj);
302
+ assert.deepEqual(layers, []);
303
+ const hit = diagnostics.find((d) => d.kind === "layer-import-failed");
304
+ assert.ok(
305
+ hit,
306
+ `expected a layer-import-failed diagnostic; got: ${JSON.stringify(diagnostics)}`,
205
307
  );
308
+ assert.equal(hit.type, "warning");
309
+ assert.equal(hit.path, join(tmp, ".pi", "agent", "steering.ts"));
310
+ assert.match(hit.message, /failed to import/);
206
311
  });
207
312
 
208
313
  it("prefers index.ts over steering.ts at the same layer", async () => {
@@ -403,30 +508,52 @@ describe("loader: loadConfigs", () => {
403
508
  );
404
509
  });
405
510
 
406
- it("handles heterogeneous config forms across layers (inner flat + outer dir)", async () => {
407
- // Inner (session cwd) uses the single-file form .pi/steering.ts;
408
- // outer ancestor uses the directory form .pi/steering/index.ts.
409
- // Both layers should be collected inner-first without the
410
- // loader tripping on the form mismatch.
411
- const outer = join(tmp, "a");
412
- const inner = join(tmp, "a", "b");
413
- mkdirSync(inner, { recursive: true });
511
+ it("handles heterogeneous forms across the two layers (project flat + global dir)", async () => {
512
+ // Project (session cwd) uses the single-file form
513
+ // .pi/steering.ts; global layer uses the directory form
514
+ // <agentDir>/steering/index.ts. Both layers should be collected
515
+ // project-first without the loader tripping on the form mismatch.
516
+ const proj = join(tmp, "proj");
517
+ mkdirSync(proj, { recursive: true });
414
518
  writeConfig(
415
- join(inner, ".pi", "steering.ts"),
416
- configModule("{ disabledRules: ['inner-flat'] }"),
519
+ join(proj, ".pi", "steering.ts"),
520
+ configModule("{ disabledRules: ['project-flat'] }"),
417
521
  );
418
522
  writeConfig(
419
- join(outer, ".pi", "steering", "index.ts"),
420
- configModule("{ disabledRules: ['outer-dir'] }"),
523
+ join(tmp, ".pi", "agent", "steering", "index.ts"),
524
+ configModule("{ disabledRules: ['global-dir'] }"),
421
525
  );
422
- const { layers } = await loadConfigs(inner);
526
+ const { layers } = await loadConfigs(proj);
423
527
  assert.deepEqual(
424
528
  layers.map((l) => l.disabledRules?.[0]),
425
- ["inner-flat", "outer-dir"],
426
- "expected inner-first ordering regardless of per-layer form",
529
+ ["project-flat", "global-dir"],
530
+ "expected project-first ordering regardless of per-layer form",
427
531
  );
428
532
  });
429
533
 
534
+ it("emits global-layer stray-file diagnostics with paths under the agent dir", async () => {
535
+ const cwd = join(tmp, "proj");
536
+ mkdirSync(cwd, { recursive: true });
537
+ // Stray non-.ts file in the GLOBAL layer's steering/ dir, with no
538
+ // index.ts — the stray-file scan must run for both layers, not
539
+ // just the project one.
540
+ mkdirSync(join(tmp, ".pi", "agent", "steering"), { recursive: true });
541
+ writeFileSync(
542
+ join(tmp, ".pi", "agent", "steering", "rules.mjs"),
543
+ "// not ts",
544
+ "utf8",
545
+ );
546
+ const { diagnostics } = await loadConfigs(cwd);
547
+ const stray = diagnostics.filter((d) => d.kind === "layer-stray-file");
548
+ assert.equal(stray.length, 1);
549
+ assert.equal(
550
+ stray[0]?.path,
551
+ join(tmp, ".pi", "agent", "steering", "rules.mjs"),
552
+ );
553
+ assert.equal(stray[0]?.type, "warning");
554
+ assert.match(stray[0]?.message ?? "", /under steering\//);
555
+ });
556
+
430
557
  it("re-imports config when file content changes between calls", async () => {
431
558
  // Regression: Node's ESM module map is keyed on URL and caches
432
559
  // indefinitely within a process. Without cache-busting, an edit
@@ -954,6 +1081,31 @@ describe("loader: loadSteeringConfig", () => {
954
1081
  assert.deepEqual(diagnostics, []);
955
1082
  });
956
1083
 
1084
+ it("project rule overrides global rule by name", async () => {
1085
+ // End-to-end: the project layer's rule shadows the global layer's
1086
+ // same-named rule (project wins on name-keyed merge), with no
1087
+ // collision diagnostic — cross-layer override is the documented
1088
+ // customization path.
1089
+ const cwd = join(tmp, "proj");
1090
+ mkdirSync(cwd, { recursive: true });
1091
+ writeConfig(
1092
+ join(cwd, ".pi", "steering.ts"),
1093
+ configModule(
1094
+ `{ rules: [{ name: "dup", tool: "bash", field: "command", pattern: /^PROJECT/, reason: "project" }] }`,
1095
+ ),
1096
+ );
1097
+ writeConfig(
1098
+ join(tmp, ".pi", "agent", "steering", "index.ts"),
1099
+ configModule(
1100
+ `{ rules: [{ name: "dup", tool: "bash", field: "command", pattern: /^GLOBAL/, reason: "global" }] }`,
1101
+ ),
1102
+ );
1103
+ const { config: merged, diagnostics } = await loadSteeringConfig(cwd);
1104
+ assert.equal(merged.rules?.length, 1);
1105
+ assert.equal(merged.rules?.[0]?.reason, "project");
1106
+ assert.deepEqual(diagnostics, []);
1107
+ });
1108
+
957
1109
  it("surfaces both loader-side and merge-side diagnostics in a single array", async () => {
958
1110
  // Stage a dual-form coexistence (loader-side warning) plus a
959
1111
  // within-layer rule-name collision (merge-side warning) so we
package/src/loader.ts CHANGED
@@ -2,15 +2,17 @@
2
2
  // Part of pi-steering.
3
3
 
4
4
  /**
5
- * TS config loader. Walk up cwd `$HOME`, find `.pi/steering/index.ts`
6
- * or `.pi/steering.ts` per layer, dynamic-import each, merge
7
- * inner-first. Per-symbol JSDoc carries the contract; see also the
8
- * {@link SteeringDiagnostic} / {@link SteeringDiagnosticKind} JSDoc
9
- * for the diagnostic stream.
5
+ * TS config loader. Two fixed layers: the project layer at
6
+ * `<cwd>/.pi/steering/` (or `.pi/steering.ts`) and the global layer
7
+ * at `<agentDir>/steering/`, dynamic-import each, merge inner-first
8
+ * (project layer wins on name-keyed collisions). Per-symbol JSDoc
9
+ * carries the contract; see also the {@link SteeringDiagnostic} /
10
+ * {@link SteeringDiagnosticKind} JSDoc for the diagnostic stream.
10
11
  */
11
12
 
12
13
  import { existsSync, readdirSync, statSync } from "node:fs";
13
- import { dirname, join, resolve } from "node:path";
14
+ import { homedir } from "node:os";
15
+ import { join } from "node:path";
14
16
  import { pathToFileURL } from "node:url";
15
17
  import { EVALUATOR_BUILTIN_TRACKERS } from "./evaluator.ts";
16
18
  import { runMergerPipeline } from "./internal/session-runtime.ts";
@@ -53,25 +55,25 @@ function assertNodeVersion(): void {
53
55
  // ---------------------------------------------------------------------------
54
56
 
55
57
  /**
56
- * Candidate file paths for a given directory's `.pi/steering/...` slot,
57
- * in priority order. First existing file wins.
58
+ * Candidate file paths for a given directory's `slot` (default
59
+ * `.pi/steering`), in priority order. First existing file wins.
58
60
  *
59
61
  * Exported for tests — not part of the library's public API.
60
62
  */
61
- export function configCandidates(dir: string): string[] {
62
- return [
63
- join(dir, ".pi", "steering", "index.ts"),
64
- join(dir, ".pi", "steering.ts"),
65
- ];
63
+ export function configCandidates(dir: string, slot = ".pi/steering"): string[] {
64
+ return [join(dir, slot, "index.ts"), join(dir, `${slot}.ts`)];
66
65
  }
67
66
 
68
67
  /**
69
- * Return the non-`.ts` files that exist under `<dir>/.pi/steering/` so
68
+ * Return the non-`.ts` files that exist under `<dir>/<slot>` so
70
69
  * callers can warn about them. Uses a best-effort fs read: a missing
71
70
  * directory returns an empty list.
72
71
  */
73
- function unexpectedFilesUnderSteering(dir: string): string[] {
74
- const steeringDir = join(dir, ".pi", "steering");
72
+ function unexpectedFilesUnderSteering(
73
+ dir: string,
74
+ slot = ".pi/steering",
75
+ ): string[] {
76
+ const steeringDir = join(dir, slot);
75
77
  if (!existsSync(steeringDir)) return [];
76
78
  try {
77
79
  const entries = readdirSync(steeringDir);
@@ -95,42 +97,40 @@ function unexpectedFilesUnderSteering(dir: string): string[] {
95
97
  }
96
98
 
97
99
  /**
98
- * Walk up from `cwd` to `$HOME` (inclusive, or to the filesystem root
99
- * if HOME is unset / outside the cwd's ancestry), returning the list
100
- * of directories INNER-FIRST so `[cwd, cwd/parent, ..., HOME]`.
100
+ * Resolve the pi agent directory: `$PI_CODING_AGENT_DIR` when set
101
+ * (tilde-expanded: `"~"` home, `"~/x"` `<home>/x`, anything else
102
+ * as-is), else `<home>/.pi/agent`. Mirrors pi's `getAgentDir()` in
103
+ * @earendil-works/pi-coding-agent.
101
104
  *
102
105
  * Exported for tests.
103
106
  */
104
- export function ancestorChain(cwd: string): string[] {
105
- const home = process.env["HOME"] ?? "";
106
- const out: string[] = [];
107
- const seen = new Set<string>();
108
- let current = resolve(cwd);
109
- while (true) {
110
- if (seen.has(current)) break; // symlink-loop guard
111
- seen.add(current);
112
- out.push(current);
113
- if (current === home || current === "/") break;
114
- const parent = dirname(current);
115
- if (parent === current) break; // filesystem root
116
- current = parent;
107
+ export function resolveAgentDir(): string {
108
+ const envDir = process.env["PI_CODING_AGENT_DIR"];
109
+ if (envDir !== undefined && envDir !== "") {
110
+ // mirror pi's expandTildePath: "~" -> homedir, "~/x" -> homedir/x, else as-is
111
+ if (envDir === "~") return homedir();
112
+ if (envDir.startsWith("~/")) return join(homedir(), envDir.slice(2));
113
+ return envDir;
117
114
  }
118
- return out;
115
+ return join(homedir(), ".pi", "agent");
119
116
  }
120
117
 
121
118
  /**
122
119
  * Find the config file (if any) for a single layer. Returns the
123
120
  * resolved file path and a `layer-form-coexistence` diagnostic when
124
- * both `.pi/steering/index.ts` and `.pi/steering.ts` coexist in the
125
- * same directory (the directory form wins).
121
+ * both `<slot>/index.ts` and `<slot>.ts` coexist in the same
122
+ * directory (the directory form wins).
126
123
  *
127
124
  * Exported for tests.
128
125
  */
129
- export function findConfigFile(dir: string): {
126
+ export function findConfigFile(
127
+ dir: string,
128
+ slot = ".pi/steering",
129
+ ): {
130
130
  file: string | null;
131
131
  diagnostic: SteeringDiagnostic | null;
132
132
  } {
133
- const [indexForm, flatForm] = configCandidates(dir);
133
+ const [indexForm, flatForm] = configCandidates(dir, slot);
134
134
  const indexExists = indexForm !== undefined && existsSync(indexForm);
135
135
  const flatExists = flatForm !== undefined && existsSync(flatForm);
136
136
  let diagnostic: SteeringDiagnostic | null = null;
@@ -144,8 +144,8 @@ export function findConfigFile(dir: string): {
144
144
  kind: "layer-form-coexistence",
145
145
  path: dir,
146
146
  message:
147
- "both .pi/steering.ts and .pi/steering/index.ts exist; using " +
148
- "directory form. Delete .pi/steering.ts to remove this warning.",
147
+ `both ${slot}.ts and ${slot}/index.ts exist; using ` +
148
+ `directory form. Delete ${slot}.ts to remove this warning.`,
149
149
  };
150
150
  }
151
151
  if (indexExists) return { file: indexForm ?? null, diagnostic };
@@ -223,12 +223,15 @@ async function importConfigFile(path: string): Promise<SteeringConfig> {
223
223
  }
224
224
 
225
225
  /**
226
- * Walk up from `cwd` collecting config layers. Returns INNER-FIRST
227
- * (caller passes to {@link buildConfig}, which expects inner-first so
228
- * early entries take precedence on collisions).
226
+ * Load the two fixed config layers for `cwd`: the project layer at
227
+ * `<cwd>/.pi/steering/` (or `.pi/steering.ts`) and the global layer
228
+ * at `<agentDir>/steering/` (see {@link resolveAgentDir}). Returns
229
+ * the layers INNER-FIRST (project first, then global; caller passes
230
+ * to {@link buildConfig}, which expects inner-first so early entries
231
+ * take precedence on collisions).
229
232
  *
230
233
  * Issues encountered along the way (per-layer import failure, dual
231
- * form coexistence, stray non-`.ts` file under `.pi/steering/`)
234
+ * form coexistence, stray non-`.ts` file under the layer directory)
232
235
  * surface as structured {@link SteeringDiagnostic} entries on the
233
236
  * returned object. The loader does not log to `console.warn` directly
234
237
  * — the bridge runtime owns the policy decision (throw vs. log) once
@@ -242,46 +245,59 @@ export async function loadConfigs(cwd: string): Promise<{
242
245
  }> {
243
246
  assertNodeVersion();
244
247
 
245
- const dirs = ancestorChain(cwd);
246
248
  const layers: SteeringConfig[] = [];
247
249
  const diagnostics: SteeringDiagnostic[] = [];
248
- for (const dir of dirs) {
249
- const { file, diagnostic } = findConfigFile(dir);
250
- if (diagnostic !== null) diagnostics.push(diagnostic);
251
- if (file === null) {
252
- // Surface stray files under `.pi/steering/` that the loader
253
- // won't pick up. Only check when the directory exists but has
254
- // no `index.ts` otherwise a project without any steering
255
- // directory would emit noise.
256
- const steeringDir = join(dir, ".pi", "steering");
257
- if (existsSync(steeringDir)) {
258
- for (const stray of unexpectedFilesUnderSteering(dir)) {
259
- diagnostics.push({
260
- type: "warning",
261
- kind: "layer-stray-file",
262
- path: stray,
263
- message: "ignoring non-.ts file under .pi/steering/",
264
- });
265
- }
250
+ await loadLayer(cwd, ".pi/steering", layers, diagnostics);
251
+ await loadLayer(resolveAgentDir(), "steering", layers, diagnostics);
252
+ return { layers, diagnostics };
253
+ }
254
+
255
+ /**
256
+ * Load a single layer: `slot` under `dir` (candidate file discovery,
257
+ * coexistence diagnostic, stray-file scan, dynamic import). Shared by
258
+ * the project layer (`cwd` + `.pi/steering`) and the global layer
259
+ * (`agentDir` + `steering`).
260
+ */
261
+ async function loadLayer(
262
+ dir: string,
263
+ slot: string,
264
+ layers: SteeringConfig[],
265
+ diagnostics: SteeringDiagnostic[],
266
+ ): Promise<void> {
267
+ const { file, diagnostic } = findConfigFile(dir, slot);
268
+ if (diagnostic !== null) diagnostics.push(diagnostic);
269
+ if (file === null) {
270
+ // Surface stray files under `<slot>/` that the loader won't pick
271
+ // up. Only check when the directory exists but has no `index.ts`
272
+ // — otherwise a project without any steering directory would
273
+ // emit noise.
274
+ const slotDir = join(dir, slot);
275
+ if (existsSync(slotDir)) {
276
+ for (const stray of unexpectedFilesUnderSteering(dir, slot)) {
277
+ diagnostics.push({
278
+ type: "warning",
279
+ kind: "layer-stray-file",
280
+ path: stray,
281
+ message: `ignoring non-.ts file under ${slot}/`,
282
+ });
266
283
  }
267
- continue;
268
- }
269
- try {
270
- layers.push(await importConfigFile(file));
271
- } catch (err) {
272
- // Use err.message to drop the `Error: ` class prefix; native
273
- // runtime errors (jiti syntax errors) may embed their path inside
274
- // the message and we accept that duplication.
275
- const body = err instanceof Error ? err.message : String(err);
276
- diagnostics.push({
277
- type: "warning",
278
- kind: "layer-import-failed",
279
- path: file,
280
- message: `failed to import: ${body}`,
281
- });
282
284
  }
285
+ return;
286
+ }
287
+ try {
288
+ layers.push(await importConfigFile(file));
289
+ } catch (err) {
290
+ // Use err.message to drop the `Error: ` class prefix; native
291
+ // runtime errors (jiti syntax errors) may embed their path inside
292
+ // the message and we accept that duplication.
293
+ const body = err instanceof Error ? err.message : String(err);
294
+ diagnostics.push({
295
+ type: "warning",
296
+ kind: "layer-import-failed",
297
+ path: file,
298
+ message: `failed to import: ${body}`,
299
+ });
283
300
  }
284
- return { layers, diagnostics };
285
301
  }
286
302
 
287
303
  // ---------------------------------------------------------------------------
@@ -428,10 +444,11 @@ function mergeStringUnion(
428
444
  }
429
445
 
430
446
  /**
431
- * Inner-wins boolean merge over walked-up layers. Walks left-to-right
432
- * (inner-first); returns the first explicit boolean or `undefined`.
433
- * Used by `buildConfig` and the session runtime for the inner-wins
434
- * boolean fields. Internal — not in the package's `exports` surface.
447
+ * Inner-wins boolean merge over the inner-first layers. Walks
448
+ * left-to-right (inner-first); returns the first explicit boolean or
449
+ * `undefined`. Used by `buildConfig` and the session runtime for the
450
+ * inner-wins boolean fields. Internal — not in the package's
451
+ * `exports` surface.
435
452
  */
436
453
  export function mergeBool(
437
454
  layers: readonly SteeringConfig[],