Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- **Settings preservation during evolve**: `jumbo evolve --yes` now additively inserts missing `.jumbo/settings.jsonc` defaults without overwriting customized known values or unknown user settings, and invalid JSONC settings files fail clearly instead of being partially rewritten.

## [3.12.0] - 2026-07-04

### Added
Expand Down
1 change: 1 addition & 0 deletions docs/getting-started/what-jumbo-creates.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ The `.jumbo/` directory is **local project memory**. It is not yet designed for
## Configuration

Jumbo stores project-level settings in `.jumbo/settings.jsonc`. The file supports comments (JSONC format).
When `jumbo evolve --yes` or settings persistence updates this file, Jumbo preserves explicit values and unknown entries while adding any missing defaults.

Default contents:

Expand Down
4 changes: 2 additions & 2 deletions docs/reference/commands/maintenance.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,11 @@ Runs the complete installation update workflow in sequence:
3. Convert legacy component-coupling dependencies into relations
4. Refresh `AGENTS.md` and managed agent instruction files
5. Sync Jumbo-managed skills from `assets/skills/`
6. Ensure settings files exist
6. Ensure settings files exist and add missing default settings without overwriting user values
7. Refresh managed harness and hook configuration
8. Rebuild database projections from the event store

The command is designed to be idempotent. Re-running it after a successful evolve should not create duplicate events or duplicate relations.
The command is designed to be idempotent. Re-running it after a successful evolve should not create duplicate events or duplicate relations. Existing `.jumbo/settings.jsonc` files are upgraded additively: missing defaults are inserted, explicit values and unknown entries are preserved, and invalid JSONC fails instead of being partially rewritten.

### Examples

Expand Down
25 changes: 21 additions & 4 deletions src/infrastructure/settings/FsSettingsInitializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ import fs from "fs-extra";
import path from "path";
import { ISettingsInitializer } from "../../application/settings/ISettingsInitializer.js";
import { PlannedFileChange } from "../../application/context/project/init/PlannedFileChange.js";
import { DEFAULT_SETTINGS } from "./DefaultSettings.js";
import { JsonObject, applyMissingDefaults, assertValidJsonc } from "./JsoncSettingsEditor.js";

/**
* FsSettingsInitializer - Creates settings file with defaults if it doesn't exist.
* FsSettingsInitializer - Creates settings file with defaults if it doesn't exist,
* and additively fills in any missing default sections or fields if it does.
*
* Called during bootstrap to ensure settings infrastructure is ready.
* Only creates the file if missing - doesn't overwrite existing settings.
* Called during bootstrap and evolve to ensure settings infrastructure is current.
* Existing explicit values and unknown entries are always preserved.
*/
export class FsSettingsInitializer implements ISettingsInitializer {
private readonly settingsFilePath: string;
Expand All @@ -17,8 +20,8 @@ export class FsSettingsInitializer implements ISettingsInitializer {
}

async ensureSettingsFileExists(): Promise<void> {
// Only create if it doesn't exist
if (await fs.pathExists(this.settingsFilePath)) {
await this.applyMissingDefaults();
return;
}

Expand Down Expand Up @@ -70,6 +73,20 @@ export class FsSettingsInitializer implements ISettingsInitializer {
await fs.writeFile(this.settingsFilePath, content, "utf-8");
}

/**
* Fill in any missing default sections or fields on an existing settings
* file, leaving explicit values and unknown entries untouched.
*/
private async applyMissingDefaults(): Promise<void> {
const content = await fs.readFile(this.settingsFilePath, "utf-8");
assertValidJsonc(content, this.settingsFilePath);

const updated = applyMissingDefaults(content, DEFAULT_SETTINGS as unknown as JsonObject);
if (updated !== content) {
await fs.writeFile(this.settingsFilePath, updated, "utf-8");
}
}

async getPlannedFileChange(): Promise<PlannedFileChange | null> {
if (await fs.pathExists(this.settingsFilePath)) {
return null; // No change needed
Expand Down
53 changes: 51 additions & 2 deletions src/infrastructure/settings/FsSettingsReader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as jsonc from "jsonc-parser";
import { ISettingsReader } from "../../application/settings/ISettingsReader.js";
import { Settings } from "../../application/settings/Settings.js";
import { DEFAULT_SETTINGS } from "./DefaultSettings.js";
import { JsonValue, setJsoncValue } from "./JsoncSettingsEditor.js";

/**
* JsoncSettingsReader - File system implementation for reading settings.
Expand Down Expand Up @@ -55,8 +56,56 @@ export class FsSettingsReader implements ISettingsReader {

async write(settings: Settings): Promise<void> {
const normalizedSettings = this.mergeWithDefaults(settings);
const content = this.buildSettingsFileContent(normalizedSettings);
await fs.writeFile(this.settingsFilePath, content, "utf-8");

if (!(await fs.pathExists(this.settingsFilePath))) {
const content = this.buildSettingsFileContent(normalizedSettings);
await fs.writeFile(this.settingsFilePath, content, "utf-8");
return;
}

const existingContent = await fs.readFile(this.settingsFilePath, "utf-8");
const errors: jsonc.ParseError[] = [];
jsonc.parse(existingContent, errors, { allowTrailingComma: true });
if (errors.length > 0) {
throw new Error(
`Invalid JSON in settings file: ${this.formatParseErrors(errors)}`
);
}

let updated = existingContent;
for (const { path: fieldPath, value } of this.knownValueEntries(normalizedSettings)) {
updated = setJsoncValue(updated, fieldPath, value);
}
await fs.writeFile(this.settingsFilePath, updated, "utf-8");
}

/**
* Known settings leaf paths and their values, used to persist changes onto
* an existing settings file without disturbing unknown entries elsewhere
* in the document.
*/
private knownValueEntries(
settings: Settings
): Array<{ path: jsonc.JSONPath; value: JsonValue }> {
return [
{ path: ["project", "id"], value: settings.project!.id },
{ path: ["qa", "defaultTurnLimit"], value: settings.qa.defaultTurnLimit },
{
path: ["claims", "claimDurationMinutes"],
value: settings.claims.claimDurationMinutes,
},
{ path: ["telemetry", "enabled"], value: settings.telemetry.enabled },
{ path: ["telemetry", "anonymousId"], value: settings.telemetry.anonymousId },
{ path: ["telemetry", "consentGiven"], value: settings.telemetry.consentGiven },
{
path: ["tui", "showLaunchpadWelcome"],
value: settings.tui!.showLaunchpadWelcome,
},
{
path: ["session", "backlogPreviewSize"],
value: settings.session!.backlogPreviewSize,
},
];
}

async hasTelemetryConfiguration(): Promise<boolean> {
Expand Down
101 changes: 101 additions & 0 deletions src/infrastructure/settings/JsoncSettingsEditor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import * as jsonc from "jsonc-parser";

/**
* Recursive JSON value shape used to walk settings documents and defaults
* without resorting to `any` at the JSONC editing boundary.
*/
export type JsonValue =
| string
| number
| boolean
| null
| JsonValue[]
| { [key: string]: JsonValue };

export type JsonObject = { [key: string]: JsonValue };

const FORMATTING_OPTIONS: jsonc.FormattingOptions = {
insertSpaces: true,
tabSize: 2,
eol: "\n",
};

function isPlainObject(value: JsonValue): value is JsonObject {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

/**
* Parse settings JSONC content, throwing a descriptive error if it is invalid.
* Used at every write path so malformed files fail loudly instead of being
* partially rewritten.
*/
export function assertValidJsonc(content: string, filePath: string): void {
const errors: jsonc.ParseError[] = [];
jsonc.parse(content, errors, { allowTrailingComma: true });
if (errors.length > 0) {
const formatted = errors
.map((err) => `Error at offset ${err.offset}: ${jsonc.printParseErrorCode(err.error)}`)
.join(", ");
throw new Error(`Invalid JSON in settings file at ${filePath}: ${formatted}`);
}
}

/**
* Set a single value at `path` within JSONC `content`, preserving comments,
* formatting, and every other entry in the document.
*/
export function setJsoncValue(content: string, path: jsonc.JSONPath, value: JsonValue): string {
const edits = jsonc.modify(content, path, value, { formattingOptions: FORMATTING_OPTIONS });
return jsonc.applyEdits(content, edits);
}

function isMissing(content: string, path: jsonc.JSONPath): boolean {
const tree = jsonc.parseTree(content);
if (!tree) {
return true;
}
return jsonc.findNodeAtLocation(tree, path) === undefined;
}

export interface MissingEntry {
path: jsonc.JSONPath;
value: JsonValue;
}

/**
* Walk `defaults` against existing JSONC `content` and collect entries that
* are absent. Whole subtrees are reported as a single entry when their parent
* is entirely missing; otherwise only the missing nested fields are reported.
* Collected entries never nest one inside another, so they can be applied to
* `content` in any order without conflicting.
*/
export function collectMissingDefaults(
content: string,
defaults: JsonObject,
basePath: jsonc.JSONPath = []
): MissingEntry[] {
const entries: MissingEntry[] = [];
for (const key of Object.keys(defaults)) {
const path = [...basePath, key];
const value = defaults[key];
if (isMissing(content, path)) {
entries.push({ path, value });
} else if (isPlainObject(value)) {
entries.push(...collectMissingDefaults(content, value, path));
}
}
return entries;
}

/**
* Apply every missing default entry to `content`, returning the updated
* document. Returns `content` unchanged if there is nothing to add.
*/
export function applyMissingDefaults(content: string, defaults: JsonObject): string {
const entries = collectMissingDefaults(content, defaults);
let updated = content;
for (const entry of entries) {
updated = setJsoncValue(updated, entry.path, entry.value);
}
return updated;
}
90 changes: 90 additions & 0 deletions tests/infrastructure/settings/FsSettingsInitializer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,94 @@ describe("FsSettingsInitializer", () => {
const settings = await new FsSettingsReader(tempDir).read();
expect(settings.session).toEqual(DEFAULT_SETTINGS.session);
});

it("adds missing default sections to an existing settings file", async () => {
const tempDir = await createTempDir();
const settingsPath = path.join(tempDir, "settings.jsonc");
await fs.writeFile(
settingsPath,
JSON.stringify({
qa: { defaultTurnLimit: 7 },
claims: { claimDurationMinutes: 45 },
telemetry: { enabled: false, anonymousId: null, consentGiven: true },
}),
"utf-8",
);
const initializer = new FsSettingsInitializer(tempDir);

await initializer.ensureSettingsFileExists();

const settings = await new FsSettingsReader(tempDir).read();
expect(settings.project).toEqual(DEFAULT_SETTINGS.project);
expect(settings.tui).toEqual(DEFAULT_SETTINGS.tui);
expect(settings.session).toEqual(DEFAULT_SETTINGS.session);
});

it("preserves explicit known values already present in the settings file", async () => {
const tempDir = await createTempDir();
const settingsPath = path.join(tempDir, "settings.jsonc");
await fs.writeFile(
settingsPath,
JSON.stringify({
project: { id: "11111111-1111-4111-8111-111111111111" },
qa: { defaultTurnLimit: 7 },
claims: { claimDurationMinutes: 45 },
telemetry: { enabled: false, anonymousId: null, consentGiven: true },
tui: { showLaunchpadWelcome: false },
session: { backlogPreviewSize: 2 },
}),
"utf-8",
);
const initializer = new FsSettingsInitializer(tempDir);

await initializer.ensureSettingsFileExists();

const settings = await new FsSettingsReader(tempDir).read();
expect(settings.project).toEqual({ id: "11111111-1111-4111-8111-111111111111" });
expect(settings.qa).toEqual({ defaultTurnLimit: 7 });
expect(settings.claims).toEqual({ claimDurationMinutes: 45 });
expect(settings.telemetry).toEqual({ enabled: false, anonymousId: null, consentGiven: true });
expect(settings.tui).toEqual({ showLaunchpadWelcome: false });
expect(settings.session).toEqual({ backlogPreviewSize: 2 });
});

it("preserves unknown top-level and nested entries when filling in defaults", async () => {
const tempDir = await createTempDir();
const settingsPath = path.join(tempDir, "settings.jsonc");
await fs.writeFile(
settingsPath,
JSON.stringify({
qa: { defaultTurnLimit: 7, customField: "keep-me" },
claims: { claimDurationMinutes: 45 },
telemetry: { enabled: false, anonymousId: null, consentGiven: true },
experimental: { flagA: true },
}),
"utf-8",
);
const initializer = new FsSettingsInitializer(tempDir);

await initializer.ensureSettingsFileExists();

const rawContent = await fs.readFile(settingsPath, "utf-8");
const raw = JSON.parse(
rawContent
.split("\n")
.filter((line) => !line.trim().startsWith("//"))
.join("\n"),
);
expect(raw.qa.customField).toBe("keep-me");
expect(raw.experimental).toEqual({ flagA: true });
});

it("throws a clear error instead of rewriting an invalid JSONC settings file", async () => {
const tempDir = await createTempDir();
const settingsPath = path.join(tempDir, "settings.jsonc");
await fs.writeFile(settingsPath, "{ invalid json ", "utf-8");
const initializer = new FsSettingsInitializer(tempDir);

await expect(initializer.ensureSettingsFileExists()).rejects.toThrow();

const contentAfterFailure = await fs.readFile(settingsPath, "utf-8");
expect(contentAfterFailure).toBe("{ invalid json ");
});
});
28 changes: 28 additions & 0 deletions tests/infrastructure/settings/FsSettingsReader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,4 +117,32 @@ describe("FsSettingsReader", () => {
session: { backlogPreviewSize: 2 },
});
});

it("preserves unknown top-level and nested entries when persisting known settings changes", async () => {
const tempDir = await createTempDir();
const settingsPath = path.join(tempDir, "settings.jsonc");
await fs.writeFile(
settingsPath,
JSON.stringify({
...DEFAULT_SETTINGS,
qa: { defaultTurnLimit: 3, customField: "keep-me" },
experimental: { flagA: true },
}),
"utf-8",
);
const reader = new FsSettingsReader(tempDir);

await reader.write({
...DEFAULT_SETTINGS,
tui: { showLaunchpadWelcome: false },
});

const rawContent = await fs.readFile(settingsPath, "utf-8");
const raw = JSON.parse(rawContent);
expect(raw.qa.customField).toBe("keep-me");
expect(raw.experimental).toEqual({ flagA: true });

const settings = await reader.read();
expect(settings.tui).toEqual({ showLaunchpadWelcome: false });
});
});
Loading
Loading