pi-orchestrator-config 4.26.1 → 4.27.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 CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## [4.27.0] - 2026-11-07
4
+
5
+ - Settings writes keep a configurable history: the previous file is copied to `.bak.1` before every
6
+ write, older copies rotate up to `settings_backup_count` (default 5, range 0-20,
7
+ `PI_SETTINGS_BACKUP_COUNT`), and lowering the count prunes the extra copies. A write whose backup
8
+ cannot be made is refused rather than proceeding without a fallback.
9
+ - A settings file that does not parse is never replaced: the save is refused
10
+ (`SettingsFileCorruptError`) and the file is left byte-identical, so a hand-repairable file cannot
11
+ be emptied by generated content.
12
+ - Test processes can no longer write real settings: under `node:test`, writes outside the temp tree
13
+ are refused, checked against the **real** path so a symlinked `.pi` cannot reach a real project.
14
+ - An unscoped `tool_name bash` memory rule is refused at write time and ignored at load time, so a
15
+ memory entry can neither block every command nor append a banner to every result. "always
16
+ ask_user before X" is promoted as an ordering verifier instead of a blanket warning.
17
+ - Log fallbacks stay inside the project: `.pi/tmp/pi-logs` is tried before the home copy, and a
18
+ vanished working directory no longer throws while the fallback list is built.
19
+ - The qodo-review prompt writes its context under `.pi/tmp`, not `/tmp`.
20
+
3
21
  ## [4.26.1] - 2026-11-06
4
22
 
5
23
  - The tmp sweep carries no hardcoded control-file name list: it asks
@@ -16,9 +16,12 @@ import {
16
16
  type EnforcementTrigger,
17
17
  type EnforcementAction,
18
18
  } from "./memory-scoring.js";
19
+ import { createLogger } from "../shared/logger.js";
19
20
  import { readAllTopicEntries } from "./memory-tree.js";
20
21
  import { getSetting } from "./project-settings.js";
21
22
 
23
+ const log = createLogger("enforcement");
24
+
22
25
  // ── Types ──────────────────────────────────────────────────────────────────
23
26
 
24
27
  export interface EnforcedEntry {
@@ -84,6 +87,16 @@ export function loadEnforcedEntries(cwd: string, sessionId?: string): EnforcedEn
84
87
 
85
88
  for (const { hash, entry } of all) {
86
89
  if (!entry.trigger || !entry.action) continue;
90
+ // A `tool_name bash` rule fires on every command: block stops the session dead, warn
91
+ // buries every result under a banner. Both happened with entries already in the store,
92
+ // so the guard lives at load time as well as write time.
93
+ if (
94
+ entry.trigger.trim().toLowerCase() === "tool_name bash" &&
95
+ ["block", "warn"].includes(entry.action.trim().split(/\s+/)[0])
96
+ ) {
97
+ log.warn("enforcement_rule_unscoped_bash_ignored", { hash, action: entry.action });
98
+ continue;
99
+ }
87
100
 
88
101
  result.push({
89
102
  hash,
@@ -38,8 +38,10 @@ export const EVIDENCE_PROJECT_RULE = 5;
38
38
  const log = createLogger("memory");
39
39
 
40
40
  export interface InferredEnforcement {
41
- trigger: EnforcementTrigger;
42
- action: EnforcementAction;
41
+ /** A rule can be ordering-only: then the verifier is the enforcement and there is no
42
+ * trigger/action pair to fire unconditionally. */
43
+ trigger?: EnforcementTrigger;
44
+ action?: EnforcementAction;
43
45
  actionCommand?: string;
44
46
  verifier?: string;
45
47
  confidence: "high" | "low";
@@ -94,9 +96,11 @@ export function inferMechanicalEnforcement(text: string): InferredEnforcement |
94
96
  if (askBefore?.[1]) {
95
97
  const cmd = askBefore[1].replace(/[.!]+$/, "").trim();
96
98
  if (cmd.length > 2 && cmd.length < 80) {
99
+ // The rule is about ORDER, so the verifier is the enforcement. A `bash_contains` warn
100
+ // would also fire after a compliant `ask_user` + command pair, which is exactly the
101
+ // noise this story keeps producing; and a bare `tool_name bash` is refused at write
102
+ // time and ignored at load time.
97
103
  return {
98
- trigger: "tool_name bash" as EnforcementTrigger,
99
- action: "warn",
100
104
  verifier: `tool_called ask_user before ${cmd}`,
101
105
  confidence: "high",
102
106
  };
@@ -105,6 +105,21 @@ function validateTrigger(trigger: string): string | null {
105
105
  return null;
106
106
  }
107
107
 
108
+ /** A `tool_name bash` rule fires on EVERY command, so a block (or a warn) there can brick
109
+ * the session instead of enforcing anything specific. Two of them did exactly that: one
110
+ * blocked every bash call, the other printed a banner on every call. Such a rule is never
111
+ * what the writer means — a bash rule has to name the command it is about. */
112
+ export function unscopedBashRuleError(trigger: string, action: string | undefined): string | null {
113
+ if (trigger.trim().toLowerCase() !== "tool_name bash") return null;
114
+ const verb = (action ?? "").trim().split(/\s+/)[0];
115
+ if (verb !== "block" && verb !== "warn") return null;
116
+ log.warn("memory-write: unscoped bash trigger rejected", { trigger, action });
117
+ return (
118
+ `Refusing "${trigger}" with action "${verb}": it would apply to EVERY bash call. ` +
119
+ "Scope it to the command, e.g. 'bash_contains rm -rf /' or 'bash_regex ^git push'."
120
+ );
121
+ }
122
+
108
123
  function validateAction(action: string): string | null {
109
124
  const validActions = ["block", "warn"];
110
125
  const isRunAfter = action.startsWith("run_after ");
@@ -2366,6 +2381,8 @@ export async function addMemoryEntry(cwd: string, input: AddMemoryInput): Promis
2366
2381
  if (input.trigger) {
2367
2382
  const err = validateTrigger(input.trigger);
2368
2383
  if (err) return { status: "rejected", message: err };
2384
+ const unscoped = unscopedBashRuleError(input.trigger, input.action);
2385
+ if (unscoped) return { status: "rejected", message: unscoped };
2369
2386
  }
2370
2387
  if (input.action) {
2371
2388
  const err = validateAction(input.action);
@@ -3,13 +3,14 @@
3
3
  * No pi-coding-agent or pi-tui dependencies.
4
4
  */
5
5
 
6
- import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
7
- import { homedir } from "node:os";
8
- import { dirname, join as joinPath } from "node:path";
6
+ import { appendFileSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
7
+ import { homedir, tmpdir } from "node:os";
8
+ import { basename, dirname, join as joinPath, resolve, sep } from "node:path";
9
9
  import stripJsonComments from "strip-json-comments";
10
10
  import {
11
11
  type SettingsKeyDef,
12
12
  SETTINGS_KEYS,
13
+ getSetting,
13
14
  getSettingsPath,
14
15
  getGlobalSettingsPath,
15
16
  } from "./project-settings.js";
@@ -209,11 +210,163 @@ export function readSettingsFile(filePath: string): Record<string, unknown> | nu
209
210
  return null;
210
211
  }
211
212
 
213
+ /** Thrown instead of writing when the target file cannot be parsed. A settings file that a
214
+ * human can still repair by hand must never be replaced by generated content. */
215
+ export class SettingsFileCorruptError extends Error {
216
+ constructor(readonly filePath: string) {
217
+ super(`refusing to overwrite unparseable settings file: ${filePath}`);
218
+ this.name = "SettingsFileCorruptError";
219
+ log.error("settings_write_refused_corrupt", { filePath });
220
+ }
221
+ }
222
+
223
+ /** `realpath` of the nearest path that exists, so a symlinked parent cannot hide the target. */
224
+ function nearestRealPath(target: string): string {
225
+ const absolute = resolve(target);
226
+ const tail: string[] = [];
227
+ let current = absolute;
228
+ for (;;) {
229
+ try {
230
+ const real = realpathSync(current);
231
+ return tail.length > 0 ? joinPath(real, ...tail.reverse()) : real;
232
+ } catch {
233
+ const parent = dirname(current);
234
+ if (parent === current) return absolute;
235
+ tail.push(basename(current));
236
+ current = parent;
237
+ }
238
+ }
239
+ }
240
+
241
+ /** The cwd whose settings govern this file: the project root for a project file, and never a
242
+ * project override for a global one. A removed cwd must not block a save. */
243
+ function settingCwdFor(filePath: string): string | null {
244
+ const parent = dirname(filePath);
245
+ if (basename(parent) === ".pi") {
246
+ const root = dirname(parent);
247
+ if (root === homedir()) return null; // global file: no project scope applies
248
+ return root;
249
+ }
250
+ try {
251
+ return process.cwd();
252
+ } catch {
253
+ return null;
254
+ }
255
+ }
256
+
257
+ /** Backup retention for this file: project setting where a project owns it, env/default for
258
+ * a global file — one project can never change another's retention. */
259
+ function backupCountFor(filePath: string): number {
260
+ const def = SETTINGS_KEYS.settings_backup_count;
261
+ const fallback = Number(def?.default ?? 5);
262
+ const min = Number(def?.min ?? 0);
263
+ const max = Number(def?.max ?? 20);
264
+ // An out-of-range or non-integer value must not decide retention: `-1` would prune every
265
+ // backup and write none, and a huge value would rotate far past the documented limit.
266
+ const accept = (value: number): number | null =>
267
+ Number.isInteger(value) && value >= min && value <= max ? value : null;
268
+
269
+ // Validate the env value FIRST, for its diagnostic: `getSetting` substitutes the default
270
+ // before the range check can see it, so a malformed env var would otherwise be logged only
271
+ // on a global save and silently swallowed on a project one.
272
+ const raw = process.env.PI_SETTINGS_BACKUP_COUNT;
273
+ const envCount = raw !== undefined && raw.trim() !== "" ? accept(Number(raw)) : null;
274
+ if (raw !== undefined && raw.trim() !== "" && envCount === null) {
275
+ log.warn("settings_backup_count_invalid", { raw, min, max, fallback });
276
+ }
277
+
278
+ const cwd = settingCwdFor(filePath);
279
+ if (cwd !== null) {
280
+ const resolved = accept(Number(getSetting(cwd, "settings_backup_count")));
281
+ if (resolved !== null) return resolved;
282
+ }
283
+ return envCount ?? fallback;
284
+ }
285
+
286
+ /** Drop slots beyond the configured count, so lowering retention removes old copies. */
287
+ function pruneExtraBackups(filePath: string, count: number): void {
288
+ const prefix = `${basename(filePath)}.bak.`;
289
+ let entries: string[] = [];
290
+ try {
291
+ entries = readdirSync(dirname(filePath));
292
+ } catch (e: any) {
293
+ log.warn("settings_backup_prune_scan_failed", { filePath, error: e?.message });
294
+ return;
295
+ }
296
+ for (const name of entries) {
297
+ if (!name.startsWith(prefix)) continue;
298
+ const slot = Number(name.slice(prefix.length));
299
+ if (!Number.isFinite(slot) || slot <= count) continue;
300
+ try {
301
+ unlinkSync(joinPath(dirname(filePath), name));
302
+ } catch (e: any) {
303
+ log.warn("settings_backup_prune_failed", { filePath, slot: name, error: e?.message });
304
+ }
305
+ }
306
+ }
307
+
308
+ /** Thrown when the previous version could not be preserved, so nothing is written. */
309
+ export class SettingsBackupError extends Error {
310
+ constructor(readonly filePath: string, readonly cause?: unknown) {
311
+ super(`refusing to write settings without a backup: ${filePath}`);
312
+ this.name = "SettingsBackupError";
313
+ }
314
+ }
315
+
316
+ /** Path of the newest previous-version copy kept next to a settings file. */
317
+ export function settingsBackupPath(filePath: string, slot = 1): string {
318
+ return `${filePath}.bak.${slot}`;
319
+ }
320
+
321
+ /** Keep `count` previous versions: shift `.bak.N-1` → `.bak.N`, then current → `.bak.1`.
322
+ * A count of 0 disables backups. Failures never block the write — they only log. */
323
+ function rotateSettingsBackups(filePath: string, count: number): void {
324
+ pruneExtraBackups(filePath, count);
325
+ if (count <= 0) return;
326
+ try {
327
+ const oldest = settingsBackupPath(filePath, count);
328
+ if (existsSync(oldest)) unlinkSync(oldest);
329
+ for (let slot = count - 1; slot >= 1; slot--) {
330
+ const from = settingsBackupPath(filePath, slot);
331
+ if (existsSync(from)) renameSync(from, settingsBackupPath(filePath, slot + 1));
332
+ }
333
+ copyFileSync(filePath, settingsBackupPath(filePath, 1));
334
+ } catch (e: any) {
335
+ // A write without its backup breaks the one guarantee this function exists for: if the
336
+ // new content is wrong there is nothing to fall back to.
337
+ log.error("settings_backup_failed", { filePath, error: e?.message });
338
+ throw new SettingsBackupError(filePath, e);
339
+ }
340
+ }
341
+
212
342
  export function writeSettingsFile(filePath: string, data: Record<string, unknown>): void {
213
343
  // Write directly to target file. Per issue spec: "comments are lost on write — acceptable
214
344
  // since the example.jsonc is the reference."
215
345
  const dir = dirname(filePath);
346
+
347
+ // Refuse BEFORE touching the filesystem: creating the parent of a rejected path would
348
+ // change a location the write is not allowed to reach.
349
+ if (process.env.NODE_TEST_CONTEXT !== undefined) {
350
+ // Test processes may only write under the OS temp tree (same safety net the global
351
+ // settings path has): a test once wrote a project's real settings file. Compare the REAL
352
+ // paths — a lexical check passes for a fixture whose `.pi` links to a real project.
353
+ const real = nearestRealPath(filePath);
354
+ const tmpRoot = realpathSync(tmpdir());
355
+ if (real !== tmpRoot && !real.startsWith(tmpRoot + sep)) {
356
+ throw new Error(`test process refused to write settings outside the temp tree: ${real}`);
357
+ }
358
+ }
359
+
216
360
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
361
+
362
+ if (existsSync(filePath)) {
363
+ // A corrupt file is recoverable by hand; a cleared one is not. Refuse rather than
364
+ // overwrite — this is exactly how a project's settings were once emptied.
365
+ if (readSettingsFile(filePath) === null) throw new SettingsFileCorruptError(filePath);
366
+ // Every touch keeps the previous bytes — `settings_backup_count` of them, newest at
367
+ // `.bak.1` — so a wrong write is always recoverable.
368
+ rotateSettingsBackups(filePath, backupCountFor(filePath));
369
+ }
217
370
  const content = JSON.stringify(data, null, 2) + "\n";
218
371
  const tmpPath = `${filePath}.${process.pid}.${Date.now().toString(36)}`;
219
372
  try {
@@ -133,7 +133,8 @@ function appendLine(filePath: string, line: string): void {
133
133
  * Append one physical line to ~/.pi/logs/<name>/<session>.log.
134
134
  * Never writes to console.* (chat UI leak).
135
135
  * Returns true on success.
136
- * On primary-path failure, tries os.tmpdir()/pi-logs/<name>.log once.
136
+ * On primary-path failure, tries `~/.pi/tmp/pi-logs` then `<cwd>/.pi/tmp/pi-logs`: both are
137
+ * swept by the tmp cleanup, and the home one still works when the checkout is read-only.
137
138
  */
138
139
  const LEVEL_ORDER: Record<string, number> = Object.assign(Object.create(null), { off: -1, debug: 0, info: 1, warn: 2, error: 3 });
139
140
 
@@ -214,13 +215,28 @@ export function writeFileLogLine(name: string, line: string): boolean {
214
215
  return true;
215
216
  } catch (primaryErr) {
216
217
  recordWriteError(primaryErr);
218
+ // Never the OS temp dir: `.pi/tmp` is what the tmp cleanup sweeps. The project comes
219
+ // first so its sweep owns the file; home is the second chance when the checkout is
220
+ // read-only or has been deleted under us.
221
+ const fallbackDirs: string[] = [];
217
222
  try {
218
- const fallbackDir = path.join(os.tmpdir(), "pi-logs");
219
- appendLine(path.join(fallbackDir, `${safe}.log`), line);
220
- return true;
221
- } catch (fallbackErr) {
222
- recordWriteError(fallbackErr);
223
- return false;
223
+ fallbackDirs.push(path.join(process.cwd(), ".pi", "tmp", "pi-logs"));
224
+ } catch {
225
+ /* cwd removed — the home fallback below still works */
224
226
  }
227
+ try {
228
+ fallbackDirs.push(path.join(os.homedir(), ".pi", "tmp", "pi-logs"));
229
+ } catch {
230
+ /* no home — nothing left to try */
231
+ }
232
+ for (const fallbackDir of fallbackDirs) {
233
+ try {
234
+ appendLine(path.join(fallbackDir, `${safe}.log`), line);
235
+ return true;
236
+ } catch (fallbackErr) {
237
+ recordWriteError(fallbackErr);
238
+ }
239
+ }
240
+ return false;
225
241
  }
226
242
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-orchestrator-config",
3
- "version": "4.26.1",
3
+ "version": "4.27.0",
4
4
  "description": "Orchestrator pattern for pi: specialist subagents, enforcement, code review loop, workflow commands",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -51,7 +51,7 @@ log in, refresh tools, or run any audit automatically.
51
51
 
52
52
  ## Attach session context
53
53
 
54
- Before each review, write a self-contained JSON context file under `/tmp`. Include:
54
+ Before each review, write a self-contained JSON context file under `.pi/tmp/` (the project scratch dir the tmp sweep cleans — never `/tmp`). Include:
55
55
 
56
56
  - what this session changed and why;
57
57
  - key implementation decisions and their rationale;
@@ -645,5 +645,14 @@
645
645
  "min": 1,
646
646
  "max": 365,
647
647
  "group": "Cleanup"
648
+ },
649
+ "settings_backup_count": {
650
+ "description": "Previous versions of a settings file kept next to it before each write (0 disables backups)",
651
+ "type": "int",
652
+ "env": "PI_SETTINGS_BACKUP_COUNT",
653
+ "default": 5,
654
+ "min": 0,
655
+ "max": 20,
656
+ "group": "Settings"
648
657
  }
649
658
  }