pi-goal-x 0.27.5 → 0.28.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/README.md CHANGED
@@ -400,12 +400,21 @@ the timestamped `.backup-*` file created next to the session.
400
400
 
401
401
  ## Configuration
402
402
 
403
- Settings are stored in:
403
+ Settings resolve per setting in this order (highest wins):
404
404
 
405
405
  ```text
406
- .pi/pi-goal-x-settings.json
406
+ environment > project layer > global layer > defaults
407
407
  ```
408
408
 
409
+ Files:
410
+
411
+ ```text
412
+ global: ~/.pi/pi-goal-x-settings.json (or $PI_CODING_AGENT_DIR, or $PI_GOAL_GLOBAL_SETTINGS_FILE)
413
+ project: <cwd>/.pi/pi-goal-x-settings.json (or $PI_GOAL_SETTINGS_FILE)
414
+ ```
415
+
416
+ Define shared configuration once in the global file and override per project. Explicit `false`/`0` values in a lower layer override inherited values; nested `keybindings` inherit per key. `/goal-settings` shows each row's effective value and source, can switch the edited scope, and can remove a local override to return to inheritance.
417
+
409
418
  Use `/goal-settings` to configure task lists, verification contracts, subtask depth, automatic goal selection, and completion auditing. Goal objectives have no hard length limit by default; set `objectiveMaxChars` (or `PI_GOAL_OBJECTIVE_MAX_CHARS`, `0` = no limit) to cap objective length across `create_goal`, `propose_goal_draft`, and `/goal-tweak`.
410
419
 
411
420
  Configure the task shortcuts in the same file when the terminal captures the defaults:
@@ -5,10 +5,15 @@ import { extractVerificationContract, sisyphusObjectiveSufficient } from "./goal
5
5
  import { detailedSummary, oneLineSummary } from "./goal-format.ts";
6
6
  import {
7
7
  goalSettingsPath,
8
+ goalGlobalSettingsPath,
8
9
  loadGoalSettings,
9
- loadGoalSettingsFileConfig,
10
- saveGoalSettingsFileConfig,
10
+ loadSettingsSnapshot,
11
+ mutateSettingsLayer,
12
+ readSettingsLayer,
13
+ envOverrideFor,
11
14
  type GoalSettings,
15
+ type SettingsMutation,
16
+ type SettingsScope,
12
17
  } from "./goal-settings.ts";
13
18
  import {
14
19
  buildGoalListText,
@@ -19,7 +24,7 @@ import {
19
24
  import { clearGoalCommandMessage, validateResumeGoal } from "./goal-policy.ts";
20
25
  import { invalidateGoalLedgerCache, readGoalLedger } from "./goal-ledger.ts";
21
26
  import { buildGoalStatusText } from "./goal-status.ts";
22
- import { effectiveSettingsReport, envOverrideFor, invalidateGoalSettingsCache } from "./goal-settings.ts";
27
+ import { effectiveSettingsReport, invalidateGoalSettingsCache, loadGoalSettingsFileConfig } from "./goal-settings.ts";
23
28
  import { invalidateGoalPoolCache, mergeGoalPromptFromDisk, readActiveGoalPool } from "./storage/goal-files.ts";
24
29
  import { nowIso, type GoalMode, type GoalRecord } from "./goal-record.ts";
25
30
  import { clearGoalDrafting, hasActiveDraft, startGoalDrafting } from "./goal-drafting.ts";
@@ -383,6 +388,7 @@ export function registerGoalCommands(core: GoalCore): void {
383
388
 
384
389
  const SETTING_ROWS: readonly SettingRow[] = [
385
390
  { key: "autoSelectSingleGoal", label: "autoSelectSingleGoal", section: "Goal behavior", kind: "boolean" },
391
+ { key: "hideUnfocusedBanner", label: "hideUnfocusedBanner", section: "Goal behavior", kind: "boolean" },
386
392
  { key: "disableContracts", label: "disableContracts", section: "Goal behavior", kind: "boolean" },
387
393
  { key: "stallTimeoutMinutes", label: "stall timeout (minutes)", section: "Goal behavior", kind: "positiveInteger" },
388
394
  { key: "objectiveMaxChars", label: "max objective length (0 = none)", section: "Goal behavior", kind: "positiveInteger" },
@@ -395,7 +401,7 @@ export function registerGoalCommands(core: GoalCore): void {
395
401
  ];
396
402
 
397
403
  function settingsValue(config: GoalSettings, key: keyof GoalSettings): string {
398
- if (key === "disabled" || key === "disableTasks" || key === "disableContracts" || key === "autoSelectSingleGoal" || key === "auditorProjectResources") {
404
+ if (key === "disabled" || key === "disableTasks" || key === "disableContracts" || key === "autoSelectSingleGoal" || key === "auditorProjectResources" || key === "hideUnfocusedBanner") {
399
405
  return config[key] === true ? "true" : "false";
400
406
  }
401
407
  if (key === "subtaskDepth") return config.subtaskDepth !== undefined ? String(config.subtaskDepth) : "1";
@@ -412,29 +418,52 @@ export function registerGoalCommands(core: GoalCore): void {
412
418
 
413
419
  async function handleSettingsMenu(ctx: ExtensionContext): Promise<void> {
414
420
  if (!ctx.hasUI) {
415
- ctx.ui.notify(`Settings file: ${goalSettingsPath(ctx.cwd)}`, "info");
421
+ // Headless: read-only report of both layer paths.
422
+ ctx.ui.notify(
423
+ `Settings — project file: ${goalSettingsPath(ctx.cwd)}\nGlobal file: ${goalGlobalSettingsPath()}`,
424
+ "info",
425
+ );
416
426
  return;
417
427
  }
428
+
418
429
  /**
419
- * Persist a settings edit, then reinstall the fixed three/five profile
420
- * when the effective disableTasks value changed since the last install.
421
- * core.tasksEnabled tracks the last profile actually installed (updated
422
- * by installGoalToolProfile), so toggling task availability off, on, and
423
- * off within one menu session reinstalls on every real change instead of
424
- * comparing against the value captured when the menu opened.
430
+ * After any mutation: reinstall the fixed three/five tool profile when
431
+ * the effective disableTasks value changed since the last install, and
432
+ * refresh the UI. This is the central settings side-effect hook — no
433
+ * ad hoc per-row refresh code.
425
434
  */
426
- const saveSettings = (next: GoalSettings): void => {
427
- saveGoalSettingsFileConfig(ctx.cwd, next);
435
+ const applyEffectiveSettings = (): void => {
428
436
  const tasksEnabledNow = !loadGoalSettings(ctx.cwd).disableTasks;
429
437
  if (tasksEnabledNow !== core.tasksEnabled) {
430
438
  core.installGoalToolProfile(tasksEnabledNow);
431
439
  }
440
+ // PR #29: settings changes must be visible immediately — the banner
441
+ // hide/restore happens through this coalesced UI refresh.
442
+ core.updateUI(ctx);
443
+ };
444
+
445
+ const applyMutation = (scope: SettingsScope, mutation: SettingsMutation): boolean => {
446
+ try {
447
+ mutateSettingsLayer({ scope, cwd: ctx.cwd, mutation });
448
+ applyEffectiveSettings();
449
+ return true;
450
+ } catch (err) {
451
+ ctx.ui.notify(`Settings change failed: ${err instanceof Error ? err.message : String(err)}`, "warning");
452
+ return false;
453
+ }
432
454
  };
455
+
456
+ const scopePath = (scope: SettingsScope): string =>
457
+ scope === "global" ? goalGlobalSettingsPath() : goalSettingsPath(ctx.cwd);
458
+
433
459
  core.enterGoalModal();
434
460
  try {
461
+ let scope: SettingsScope = "project";
435
462
  while (true) {
436
- const config = loadGoalSettingsFileConfig(ctx.cwd);
463
+ const snapshot = loadSettingsSnapshot(ctx.cwd);
464
+ const localLayer = snapshot[scope].layer as Record<string, unknown>;
437
465
  const options: string[] = [];
466
+ options.push(`─── Editing: ${scope} (${scopePath(scope)}) ───`);
438
467
  let lastSection: string | null = null;
439
468
  for (const row of SETTING_ROWS) {
440
469
  if (row.section !== lastSection) {
@@ -442,109 +471,147 @@ export function registerGoalCommands(core: GoalCore): void {
442
471
  lastSection = row.section;
443
472
  }
444
473
  const envVar = envOverrideFor(row.key);
445
- options.push(envVar ? ` ${row.label}: ${settingsValue(config, row.key)} (env: ${envVar} — read-only)` : ` ${row.label}: ${settingsValue(config, row.key)}`);
474
+ if (envVar) {
475
+ options.push(` ${row.label}: ${settingsValue(snapshot.value, row.key)} (environment; read-only)`);
476
+ continue;
477
+ }
478
+ const provenance = snapshot.provenance.get(String(row.key));
479
+ let source: string;
480
+ if (provenance?.source === "global") source = scope === "project" ? "(inherited from global)" : "(global override)";
481
+ else if (provenance?.source === "project") source = scope === "project" ? "(project override)" : "(project; edit via project scope)";
482
+ else source = "(default)";
483
+ const hasLocalOverride = localLayer[row.key] !== undefined;
484
+ if (hasLocalOverride && provenance?.source === scope) source = `(${scope} override)`;
485
+ options.push(` ${row.label}: ${settingsValue(snapshot.value, row.key)} ${source}`);
446
486
  }
447
487
  options.push("Done");
448
488
  const selected = await ctx.ui.select("Goal settings", options);
449
489
  if (!selected || selected === "Done") break;
450
- if (selected.startsWith("───")) continue; // section headers are not rows
451
- // Strip leading spaces and resolve the row from the display label.
452
- const selectedTrimmed = selected.trim();
453
- const colon = selectedTrimmed.indexOf(":");
490
+ if (selected.startsWith("───")) continue; // headers are not rows
491
+ const trimmed = selected.trim();
492
+ if (trimmed.startsWith("Scope:")) {
493
+ scope = scope === "project" ? "global" : "project";
494
+ continue;
495
+ }
496
+ const colon = trimmed.indexOf(":");
454
497
  if (colon === -1) continue;
455
- const label = selectedTrimmed.slice(0, colon).trim();
456
- const row = SETTING_ROWS.find((r) => r.label === label);
457
- if (!row) continue;
458
- const key = row.key;
459
- if (envOverrideFor(key)) {
460
- ctx.ui.notify(`${row.label} is read-only: overridden by the ${envOverrideFor(key)} env var.`, "warning");
461
- continue;
462
- }
463
- if (row.kind === "boolean") {
464
- const next = { ...config, [key]: config[key] !== true };
465
- saveSettings(next);
466
- ctx.ui.notify(`Settings saved:\n${settingsLines(loadGoalSettingsFileConfig(ctx.cwd)).join("\n")}`, "info");
467
- continue;
468
- }
469
- if (row.kind === "positiveInteger") {
470
- const input = await ctx.ui.input(`Set ${row.label}`, settingsValue(config, key));
471
- if (input === undefined) continue;
472
- // Row-driven lower bound: subtaskDepth is a nesting depth (min 1);
473
- // stallTimeoutMinutes and objectiveMaxChars default to 0 meaning
474
- // "off / no limit".
475
- const min = (row.key === "stallTimeoutMinutes" || row.key === "objectiveMaxChars") ? 0 : 1;
476
- // Full-string decimal validation: no partial parseInt. Rejects
477
- // 1.5, 1x, negatives, infinity, and unsafe integers alike.
478
- const trimmed = input.trim();
479
- if (!/^[0-9]+$/.test(trimmed) || !Number.isSafeInteger(Number(trimmed)) || Number(trimmed) < min) {
480
- ctx.ui.notify(`${row.label} must be an integer >= ${min} (e.g. ${min}, ${min + 1}, ${min + 2})`, "warning");
498
+ const label = trimmed.slice(0, colon).trim();
499
+ const row = SETTING_ROWS.find((r) => r.label === label);
500
+ if (!row) continue;
501
+ if (envOverrideFor(row.key)) {
502
+ ctx.ui.notify(`${row.label} is read-only: overridden by the ${envOverrideFor(row.key)} env var.`, "warning");
481
503
  continue;
482
504
  }
483
- const next = { ...config, [key]: Number(trimmed) };
484
- saveSettings(next);
485
- ctx.ui.notify(`Settings saved:\n${settingsLines(loadGoalSettingsFileConfig(ctx.cwd)).join("\n")}`, "info");
486
- continue;
487
- }
488
- if (row.kind === "thinking") {
489
- const currentValue = settingsValue(config, key);
490
- const levels = thinkingLevelChoices(currentValue === "(default)" ? undefined : currentValue);
491
- const picked = await ctx.ui.select(`Set ${row.label}`, levels);
492
- if (!picked) continue;
493
- const trimmed = picked.trim().replace(/^\u2713\s+/, "");
494
- const next: GoalSettings = { ...config };
495
- if (trimmed === "(default)") {
496
- delete next.thinkingLevel;
497
- } else if ((AUDITOR_THINKING_LEVELS as readonly string[]).includes(trimmed)) {
498
- next.thinkingLevel = trimmed as GoalSettings["thinkingLevel"];
499
- } else {
500
- ctx.ui.notify(`thinking_level must be one of: ${AUDITOR_THINKING_LEVELS.join(", ")} (or "(default)")`, "warning");
505
+
506
+ const inheritLabel = scope === "project" ? "Use inherited value" : "Use default (delete global override)";
507
+ const hasLocalOverride = localLayer[row.key] !== undefined;
508
+
509
+ if (row.kind === "boolean") {
510
+ const effective = snapshot.provenance.get(String(row.key))?.value === true;
511
+ const actions = [
512
+ `Set ${scope} override to true`,
513
+ `Set ${scope} override to false`,
514
+ ];
515
+ if (hasLocalOverride) actions.push(inheritLabel);
516
+ actions.push("Cancel");
517
+ const action = await ctx.ui.select(`${row.label} (${scope})`, actions);
518
+ if (!action || action === "Cancel") continue;
519
+ if (action === inheritLabel) {
520
+ applyMutation(scope, { op: "unset", path: [row.key] });
521
+ continue;
522
+ }
523
+ const value = action.endsWith("true");
524
+ if (value !== effective || !hasLocalOverride) {
525
+ applyMutation(scope, { op: "set", path: [row.key], value });
526
+ }
501
527
  continue;
502
528
  }
503
- saveSettings(next);
504
- ctx.ui.notify(`Settings saved:\n${settingsLines(loadGoalSettingsFileConfig(ctx.cwd)).join("\n")}`, "info");
505
- continue;
506
- }
507
- // modelSelector rows (provider, model): searchable auditor model
508
- // picker with current-session/default, authenticated models (\u2713
509
- // marker on the exact current selection), and a manual
510
- // provider/model entry (ll01 pattern). Either row applies the same
511
- // provider+model pair.
512
- const configured = configuredAuditorModelKey(config);
513
- const session = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
514
- const choices = buildAuditorModelChoices(ctx.modelRegistry.getAvailable(), configured, session);
515
- const filter = await ctx.ui.input("Filter auditor models (provider/id/name; blank = all)", "");
516
- if (filter === undefined) continue;
517
- const filtered = filterAuditorModelChoices(choices, filter);
518
- const picked = await ctx.ui.select("Select auditor model", filtered.map((choice) => choice.label));
519
- if (!picked) continue;
520
- const choice = filtered.find((candidate: AuditorChoice) => candidate.label === picked);
521
- if (!choice) continue;
522
- const next: GoalSettings = { ...config };
523
- if (choice.kind === "default") {
524
- delete next.provider;
525
- delete next.model;
526
- } else if (choice.kind === "manual") {
527
- const input = await ctx.ui.input("Set auditor provider/model", configured ?? "provider/model");
528
- if (input === undefined) continue;
529
- const parsed = parseManualAuditorModel(input);
530
- if ("error" in parsed) {
531
- ctx.ui.notify(parsed.error, "warning");
529
+
530
+ if (row.kind === "positiveInteger") {
531
+ const min = (row.key === "stallTimeoutMinutes" || row.key === "objectiveMaxChars") ? 0 : 1;
532
+ const actions = [`Set ${scope} override...`];
533
+ if (hasLocalOverride) actions.push(inheritLabel);
534
+ actions.push("Cancel");
535
+ const action = await ctx.ui.select(`${row.label} (${scope})`, actions);
536
+ if (!action || action === "Cancel") continue;
537
+ if (action === inheritLabel) {
538
+ applyMutation(scope, { op: "unset", path: [row.key] });
539
+ continue;
540
+ }
541
+ const input = await ctx.ui.input(`Set ${row.label}`, String(snapshot.provenance.get(String(row.key))?.value ?? min));
542
+ if (input === undefined) continue;
543
+ // Full-string decimal validation: rejects 1.5, 1x, negatives,
544
+ // infinity, and unsafe integers alike.
545
+ const value = input.trim();
546
+ if (!/^[0-9]+$/.test(value) || !Number.isSafeInteger(Number(value)) || Number(value) < min) {
547
+ ctx.ui.notify(`${row.label} must be an integer >= ${min} (e.g. ${min}, ${min + 1}, ${min + 2})`, "warning");
548
+ continue;
549
+ }
550
+ applyMutation(scope, { op: "set", path: [row.key], value: Number(value) });
532
551
  continue;
533
552
  }
534
- next.provider = parsed.provider;
535
- next.model = parsed.model;
536
- } else {
537
- next.provider = choice.provider;
538
- next.model = choice.model;
539
- }
540
- saveSettings(next);
541
- ctx.ui.notify(`Settings saved:\n${settingsLines(loadGoalSettingsFileConfig(ctx.cwd)).join("\n")}`, "info");
553
+
554
+ if (row.kind === "thinking") {
555
+ const currentValue = settingsValue(snapshot.value, row.key);
556
+ const levels = thinkingLevelChoices(currentValue === "(default)" ? undefined : currentValue);
557
+ const picked = await ctx.ui.select(`Set ${row.label} (${scope})`, levels);
558
+ if (!picked) continue;
559
+ const choice = picked.trim().replace(/^\u2713\s+/, "");
560
+ if (choice === "(default)") {
561
+ if (hasLocalOverride) applyMutation(scope, { op: "unset", path: ["thinkingLevel"] });
562
+ continue;
563
+ }
564
+ if (!(AUDITOR_THINKING_LEVELS as readonly string[]).includes(choice)) {
565
+ ctx.ui.notify(`thinking_level must be one of: ${AUDITOR_THINKING_LEVELS.join(", ")} (or "(default)")`, "warning");
566
+ continue;
567
+ }
568
+ applyMutation(scope, { op: "set", path: ["thinkingLevel"], value: choice });
569
+ continue;
570
+ }
571
+
572
+ // modelSelector rows (provider, model): searchable auditor model
573
+ // picker with explicit inherit/default handling. Either row applies
574
+ // the same provider+model pair.
575
+ const configured = configuredAuditorModelKey(snapshot.value as GoalSettings);
576
+ const session = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
577
+ const choices = buildAuditorModelChoices(ctx.modelRegistry.getAvailable(), configured, session);
578
+ const filter = await ctx.ui.input("Filter auditor models (provider/id/name; blank = all)", "");
579
+ if (filter === undefined) continue;
580
+ const filtered = filterAuditorModelChoices(choices, filter);
581
+ const picked = await ctx.ui.select("Select auditor model", filtered.map((choice: AuditorChoice) => choice.label));
582
+ if (!picked) continue;
583
+ const choice = filtered.find((candidate: AuditorChoice) => candidate.label === picked);
584
+ if (!choice) continue;
585
+ if (choice.kind === "default") {
586
+ for (const key of ["provider", "model"] as const) {
587
+ if (localLayer[key] !== undefined) applyMutation(scope, { op: "unset", path: [key] });
588
+ }
589
+ continue;
590
+ }
591
+ let providerValue: string;
592
+ let modelValue: string;
593
+ if (choice.kind === "manual") {
594
+ const input = await ctx.ui.input("Set auditor provider/model", configured ?? "provider/model");
595
+ if (input === undefined) continue;
596
+ const parsed = parseManualAuditorModel(input);
597
+ if ("error" in parsed) {
598
+ ctx.ui.notify(parsed.error, "warning");
599
+ continue;
600
+ }
601
+ providerValue = parsed.provider;
602
+ modelValue = parsed.model;
603
+ } else {
604
+ providerValue = choice.provider;
605
+ modelValue = choice.model;
606
+ }
607
+ if (applyMutation(scope, { op: "set", path: ["provider"], value: providerValue })) {
608
+ applyMutation(scope, { op: "set", path: ["model"], value: modelValue });
609
+ }
542
610
  }
543
611
  } finally {
544
612
  core.exitGoalModal();
545
613
  }
546
614
  }
547
-
548
615
  async function handleGoalClear(ctx: ExtensionContext): Promise<void> {
549
616
  core.reconcileFocusedGoalFromDisk(ctx);
550
617
  if (!core.state.goal && core.openGoals().length > 0) {