pi-orchestrator-config 4.26.0 → 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,36 @@
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
+
21
+ ## [4.26.1] - 2026-11-06
22
+
23
+ - The tmp sweep carries no hardcoded control-file name list: it asks
24
+ `daemon-manager.isLiveLegacyDaemonFile()`, which owns the two legacy names, so a future daemon
25
+ writing to `.pi/run` can never require the sweep to be edited. A legacy `pidiff.port`/`pidiff.pid`
26
+ is kept only while its recorded PID is alive (`EPERM` counts as alive — another user's running
27
+ process), and a dead daemon's leftovers plus every `reload.*` marker are swept like any other
28
+ entry. Deleting a live daemon's lockfiles would make the next connect() spawn a duplicate on its
29
+ port.
30
+ - A control file can no longer block or exhaust a session: `readLockfile` accepts only regular
31
+ files, so a project FIFO at `pidiff.port`/`pidiff.pid` is ignored, and the pid read is capped at
32
+ 64 bytes so an oversized file is rejected without being read.
33
+
3
34
  ## [4.26.0] - 2026-11-06
4
35
 
5
36
  - Async `chain` mode: steps run in sequence and each step's result is substituted into `{previous}`,
@@ -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 {
@@ -15,6 +15,7 @@ import * as fs from "node:fs";
15
15
  import { promises as fsp } from "node:fs";
16
16
  import * as path from "node:path";
17
17
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
18
+ import { isLiveLegacyDaemonFile } from "../shared/daemon-manager.js";
18
19
  import { createLogger } from "../shared/logger.js";
19
20
  import { isPiOneshotInvocation } from "../shared/oneshot.js";
20
21
  import { getGlobalSettingsPath, getSettingDynamic, getSettingsPath, SETTINGS_KEYS } from "./project-settings.js";
@@ -29,21 +30,6 @@ const DAY_MS = 24 * 60 * 60 * 1000;
29
30
  /** Defaults live in `settings-keys.json` only — never restated here. */
30
31
  export const DEFAULT_MAX_SIZE = String(SETTINGS_KEYS.project_tmp_max_size.default);
31
32
  export const DEFAULT_RETENTION_DAYS = Number(SETTINGS_KEYS.project_tmp_retention_days.default);
32
- /**
33
- * Transitional debris from pre-#1020 daemons: `.pi/tmp` was the control-file home
34
- * before `.pi/run`, so a daemon started before the upgrade still holds its pid/port and
35
- * reload handshake here, and the read-both fallback (#1025) needs these files to find it.
36
- * Sweeping them would make the next connect() miss the live daemon and spawn a duplicate
37
- * on the same port. Never deleted, whatever their age; the set disappears once no
38
- * pre-upgrade daemon remains.
39
- */
40
- export const TMP_PROTECTED_NAMES = new Set([
41
- "pidiff.pid",
42
- "pidiff.port",
43
- "reload.flag",
44
- "reload.done",
45
- "reload.pending",
46
- ]);
47
33
  /** Lives in `.pi/run`, never in the swept dir. */
48
34
  export const TMP_SWEEP_LOCK = ".tmp-sweep.lock";
49
35
  /** A crashed sweep would otherwise block every later session forever. */
@@ -171,7 +157,14 @@ export async function sweepProjectTmp(opts: TmpSweepOptions): Promise<TmpSweepRe
171
157
  let total = 0;
172
158
  for (const name of names) {
173
159
  const target = path.join(base, name);
174
- if (!isInside(base, target) || TMP_PROTECTED_NAMES.has(name)) continue;
160
+ if (!isInside(base, target)) continue;
161
+ // One exemption, decided by the daemon layer: a pre-`.pi/run` daemon that is still ALIVE
162
+ // owns its lockfile here, and sweeping it makes the next connect() spawn a duplicate on
163
+ // that port. A dead daemon's leftovers are throwaway like everything else (#1020).
164
+ if (isLiveLegacyDaemonFile(base, name)) {
165
+ log.debug("tmp_sweep_kept_live_legacy_daemon", { tmpDir: base, name });
166
+ continue;
167
+ }
175
168
  let stat: fs.Stats;
176
169
  try {
177
170
  stat = await fsp.lstat(target);
@@ -193,19 +193,36 @@ export function writeLockfile(lockDir: string, port: number, pid: number | null,
193
193
  } catch (e: any) { log(`writeLockfile error: ${e.message}`); }
194
194
  }
195
195
 
196
+ /** A pid file holds digits and a newline; anything bigger is not one. */
197
+ const MAX_PID_FILE_BYTES = 64;
198
+
199
+ /** True for a path that exists and is a regular file. Lockfiles are never FIFOs, and reading
200
+ * one would block the event loop, so every control-file read goes through this. */
201
+ function isRegularFile(target: string): boolean {
202
+ try {
203
+ return fs.lstatSync(target).isFile();
204
+ } catch {
205
+ return false;
206
+ }
207
+ }
208
+
196
209
  /** Read port from lockfile. Returns port number or null.
197
210
  * `legacyDir` (`.pi/tmp`) is consulted only when the new dir has no lockfile:
198
- * a daemon already running at upgrade time wrote its lockfile there (#1020). */
211
+ * a daemon already running at upgrade time wrote its lockfile there (#1020).
212
+ * Reading never moves or deletes anything — moving a live daemon's record is what the
213
+ * sweep's liveness check exists to avoid. */
199
214
  export function readLockfile(lockDir: string, legacyDir?: string): { port: number; pid: number | null } | null {
200
215
  const read = (dir: string): { port: number; pid: number | null } | null => {
201
216
  try {
202
217
  const portFile = path.join(dir, "pidiff.port");
203
- if (!fs.existsSync(portFile)) return null;
218
+ // Only a regular file is a lockfile: the dirs are project-controlled, and reading a
219
+ // FIFO here would block the event loop for the whole session.
220
+ if (!isRegularFile(portFile)) return null;
204
221
  const port = parseInt(fs.readFileSync(portFile, "utf-8").trim(), 10);
205
222
  if (isNaN(port)) return null;
206
223
  let pid: number | null = null;
207
224
  const pidFile = path.join(dir, "pidiff.pid");
208
- if (fs.existsSync(pidFile)) {
225
+ if (isRegularFile(pidFile)) {
209
226
  pid = parseInt(fs.readFileSync(pidFile, "utf-8").trim(), 10);
210
227
  if (isNaN(pid)) pid = null;
211
228
  }
@@ -215,6 +232,63 @@ export function readLockfile(lockDir: string, legacyDir?: string): { port: numbe
215
232
  return read(lockDir) ?? (legacyDir ? read(legacyDir) : null);
216
233
  }
217
234
 
235
+ /** Read the pid from a lockfile, refusing anything but a regular file: the legacy dir is
236
+ * project-controlled, and reading a FIFO there would block session startup. */
237
+ function readLegacyPid(pidFile: string): number | null {
238
+ try {
239
+ const st = fs.lstatSync(pidFile);
240
+ if (!st.isFile()) {
241
+ log.debug("legacy_pid_not_a_regular_file", { pidFile });
242
+ return null;
243
+ }
244
+ // The file is project-controlled: a pid is a few bytes, so refuse anything larger rather
245
+ // than reading an arbitrary file into memory during session start.
246
+ if (st.size > MAX_PID_FILE_BYTES) {
247
+ log.debug("legacy_pid_file_too_large", { pidFile, size: st.size });
248
+ return null;
249
+ }
250
+ const pid = parseInt(fs.readFileSync(pidFile, "utf-8").trim(), 10);
251
+ if (isNaN(pid) || pid <= 0) {
252
+ log.debug("legacy_pid_unparseable", { pidFile });
253
+ return null;
254
+ }
255
+ return pid;
256
+ } catch (e: any) {
257
+ log.debug("legacy_pid_unreadable", { pidFile, error: e?.message });
258
+ return null;
259
+ }
260
+ }
261
+
262
+ /** True when the legacy `.pi/tmp` lockfile belongs to a LIVE process. `EPERM` means the
263
+ * process exists under another user, so it counts as alive — treating it as dead would let
264
+ * the sweep delete a running daemon's files. */
265
+ export function legacyLockfileOwnerAlive(legacyDir: string): boolean {
266
+ const pid = readLegacyPid(path.join(legacyDir, "pidiff.pid"));
267
+ if (pid === null) return false;
268
+ try {
269
+ process.kill(pid, 0);
270
+ return true;
271
+ } catch (e: any) {
272
+ if (e?.code === "EPERM") {
273
+ log.debug("legacy_daemon_owned_by_another_user", { pid });
274
+ return true;
275
+ }
276
+ log.debug("legacy_daemon_not_running", { pid, code: e?.code });
277
+ return false;
278
+ }
279
+ }
280
+
281
+ /** Control files a pre-`.pi/run` daemon owns inside the legacy dir. Private: the sweep asks
282
+ * `isLiveLegacyDaemonFile`, so neither module keeps a second copy of the names (#1020). */
283
+ const LEGACY_CONTROL_FILES = new Set(["pidiff.port", "pidiff.pid"]);
284
+
285
+ /** True when `name` is a legacy control file whose owner is still alive — the sweep's only
286
+ * exemption. A dead daemon's leftovers are ordinary throwaway. */
287
+ export function isLiveLegacyDaemonFile(legacyDir: string, name: string): boolean {
288
+ if (!LEGACY_CONTROL_FILES.has(name)) return false;
289
+ return legacyLockfileOwnerAlive(legacyDir);
290
+ }
291
+
218
292
  /** First lockfile port (in `dirs` order) whose daemon answers /api/health, or
219
293
  * null. Exported for testing. */
220
294
  export async function findLiveLockfile(dirs: string[]): Promise<number | null> {
@@ -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.0",
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
  }