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 +18 -0
- package/extensions/orchestrator/enforcement-rules.ts +13 -0
- package/extensions/orchestrator/memory-promotion.ts +8 -4
- package/extensions/orchestrator/memory-write.ts +17 -0
- package/extensions/orchestrator/settings-tui-helpers.ts +156 -3
- package/extensions/shared/file-logger.ts +23 -7
- package/package.json +1 -1
- package/prompts/qodo-review.md +1 -1
- package/settings-keys.json +9 -0
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
|
-
|
|
42
|
-
action
|
|
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
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
package/prompts/qodo-review.md
CHANGED
|
@@ -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
|
|
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;
|
package/settings-keys.json
CHANGED
|
@@ -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
|
}
|