@bigking67/pi-67 0.10.3 → 0.10.5

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.
@@ -6,17 +6,26 @@ import { CliError } from "../lib/output.mjs";
6
6
 
7
7
  export async function xtalpiCommand(ctx, argv) {
8
8
  const [sub = "health", ...rest] = argv;
9
+ if (sub === "-h" || sub === "--help" || sub === "help") {
10
+ printXtalpiHelp();
11
+ return;
12
+ }
9
13
  if (sub === "health") return health(ctx, rest);
10
14
  if (sub === "smoke") return smoke(ctx, rest);
11
15
  if (sub === "capability") return capability(ctx, rest);
16
+ if (sub === "trend") return trend(ctx, rest);
17
+ if (sub === "drift") return drift(ctx, rest);
18
+ if (sub === "stress") return stress(ctx, rest);
19
+ if (sub === "run") return run(ctx, rest);
12
20
  throw new CliError(`unknown xtalpi command: ${sub}`, 2);
13
21
  }
14
22
 
15
23
  function health(ctx, argv) {
16
24
  const { options } = parseCommandOptions(argv, {
17
25
  strings: ["model", "provider", "timeout-ms", "attempts"],
18
- bools: ["dry-run"],
26
+ bools: ["dry-run", "json"],
19
27
  });
28
+ if (options.help) return printXtalpiHelp();
20
29
  const args = [scriptPath(ctx, "pi67-xtalpi-provider-health.mjs"), "--agent-dir", ctx.agentDir];
21
30
  if (options.provider) args.push("--provider", options.provider);
22
31
  if (options.model) args.push("--model", options.model);
@@ -30,6 +39,7 @@ function smoke(ctx, argv) {
30
39
  strings: ["case", "profile"],
31
40
  bools: ["quick", "extension-low-risk", "extension-expanded", "self-test", "dry-run"],
32
41
  });
42
+ if (options.help) return printXtalpiHelp();
33
43
  if (isWindows()) {
34
44
  const pwsh = findPowerShell();
35
45
  if (!pwsh) throw new CliError("PowerShell executable not found");
@@ -50,8 +60,9 @@ function smoke(ctx, argv) {
50
60
  function capability(ctx, argv) {
51
61
  const { options } = parseCommandOptions(argv, {
52
62
  strings: ["model", "provider", "timeout-ms"],
53
- bools: ["dry-run", "self-test"],
63
+ bools: ["dry-run", "self-test", "json"],
54
64
  });
65
+ if (options.help) return printXtalpiHelp();
55
66
  const args = [scriptPath(ctx, "pi67-xtalpi-provider-capability-probe.mjs"), "--agent-dir", ctx.agentDir];
56
67
  if (options.selfTest) args.push("--self-test");
57
68
  if (options.provider) args.push("--provider", options.provider);
@@ -60,6 +71,87 @@ function capability(ctx, argv) {
60
71
  runCommand("node", args, { cwd: ctx.repoRoot, dryRun: ctx.dryRun || options.dryRun });
61
72
  }
62
73
 
74
+ function trend(ctx, argv) {
75
+ const { options } = parseCommandOptions(argv, {
76
+ strings: ["limit", "profile", "out-dir"],
77
+ bools: ["json", "dry-run"],
78
+ });
79
+ if (options.help) return printXtalpiHelp();
80
+ const limit = options.limit || "3";
81
+ const args = [scriptPath(ctx, "pi67-xtalpi-pi-tools-debug-summary.sh"), "--trend-gate", limit];
82
+ if (options.profile) args.push("--profile", options.profile);
83
+ else args.push("--profile", "full-suite-strict");
84
+ if (ctx.json || options.json) args.push("--json");
85
+ if (options.outDir) args.push(options.outDir);
86
+ runCommand("bash", args, { cwd: ctx.repoRoot, dryRun: ctx.dryRun || options.dryRun });
87
+ }
88
+
89
+ function drift(ctx, argv) {
90
+ const { options } = parseCommandOptions(argv, {
91
+ strings: ["limit", "run-kind", "out-dir"],
92
+ bools: ["json", "dry-run"],
93
+ });
94
+ if (options.help) return printXtalpiHelp();
95
+ const limit = options.limit || "10";
96
+ const args = [scriptPath(ctx, "pi67-xtalpi-pi-tools-debug-summary.sh"), "--drift", limit];
97
+ args.push("--run-kind", options.runKind || "full-suite");
98
+ if (ctx.json || options.json) args.push("--json");
99
+ if (options.outDir) args.push(options.outDir);
100
+ runCommand("bash", args, { cwd: ctx.repoRoot, dryRun: ctx.dryRun || options.dryRun });
101
+ }
102
+
103
+ function stress(ctx, argv) {
104
+ const { options } = parseCommandOptions(argv, {
105
+ strings: ["case", "profile"],
106
+ bools: ["until-done", "dry-run"],
107
+ });
108
+ if (options.help) return printXtalpiHelp();
109
+ const smokeCase = options.untilDone ? "until-done-continuation" : options.case;
110
+ if (smokeCase) return smoke(ctx, ["--case", smokeCase, ...(options.dryRun ? ["--dry-run"] : [])]);
111
+ return smoke(ctx, ["--profile", options.profile || "full-suite", ...(options.dryRun ? ["--dry-run"] : [])]);
112
+ }
113
+
114
+ function run(ctx, argv) {
115
+ const passthroughIndex = argv.indexOf("--");
116
+ const optionArgv = passthroughIndex === -1 ? argv : argv.slice(0, passthroughIndex);
117
+ const passthrough = passthroughIndex === -1 ? [] : argv.slice(passthroughIndex + 1);
118
+ const { options, positionals } = parseCommandOptions(optionArgv, {
119
+ strings: ["model", "provider"],
120
+ bools: ["dry-run", "no-passive-observational-memory"],
121
+ });
122
+ if (options.help) return printXtalpiHelp();
123
+ const provider = options.provider || "xtalpi-pi-tools";
124
+ const model = options.model || "deepseek-v4-pro";
125
+ const piArgs = [...positionals, ...passthrough];
126
+ const env = {};
127
+ if (options.noPassiveObservationalMemory) {
128
+ env.PI_OBSERVATIONAL_MEMORY_PASSIVE = "false";
129
+ }
130
+ if (isWindows()) {
131
+ const pwsh = findPowerShell();
132
+ if (!pwsh) throw new CliError("PowerShell executable not found");
133
+ const args = [
134
+ "-NoProfile",
135
+ "-ExecutionPolicy",
136
+ "Bypass",
137
+ "-File",
138
+ scriptPath(ctx, "pi67-xtalpi-pi-tools.ps1"),
139
+ "-Provider",
140
+ provider,
141
+ "-Model",
142
+ model,
143
+ ...piArgs,
144
+ ];
145
+ runCommand(pwsh, args, { cwd: ctx.repoRoot, dryRun: ctx.dryRun || options.dryRun, env });
146
+ return;
147
+ }
148
+ runCommand("bash", [scriptPath(ctx, "pi67-xtalpi-pi-tools.sh"), ...piArgs], {
149
+ cwd: ctx.repoRoot,
150
+ dryRun: ctx.dryRun || options.dryRun,
151
+ env: { PROVIDER: provider, MODEL: model, ...env },
152
+ });
153
+ }
154
+
63
155
  function profileFromOptions(options) {
64
156
  if (options.profile) return options.profile;
65
157
  if (options.extensionLowRisk) return "extension-low-risk";
@@ -67,3 +159,38 @@ function profileFromOptions(options) {
67
159
  if (options.quick) return "quick";
68
160
  return "quick";
69
161
  }
162
+
163
+ function printXtalpiHelp() {
164
+ process.stdout.write(`pi-67 xtalpi - xtalpi health, smoke, and artifact helpers
165
+
166
+ Usage:
167
+ pi-67 xtalpi health [--provider ID] [--model NAME] [--timeout-ms N] [--attempts N]
168
+ pi-67 xtalpi smoke [--quick|--extension-low-risk|--extension-expanded|--profile NAME]
169
+ pi-67 xtalpi smoke --case NAME
170
+ pi-67 xtalpi capability [--self-test] [--provider ID] [--model NAME]
171
+ pi-67 xtalpi trend [--limit N] [--profile NAME] [--json] [--out-dir DIR]
172
+ pi-67 xtalpi drift [--limit N] [--run-kind LIST] [--json] [--out-dir DIR]
173
+ pi-67 xtalpi stress --until-done
174
+ pi-67 xtalpi run [--provider ID] [--model NAME] [--no-passive-observational-memory] [-- <pi args>]
175
+
176
+ Notes:
177
+ xtalpi-pi-tools treats xtalpi as plain chat-completions transport. Pi local
178
+ code owns tool protocol parsing, validation, repair, retry classification,
179
+ tool execution, and smoke gates.
180
+ xtalpi run uses the stable launcher and defaults
181
+ PI_OBSERVATIONAL_MEMORY_PASSIVE=true so post-final background memory writes
182
+ cannot hold the main task lifecycle open. Pass --no-passive-observational-memory
183
+ only when you explicitly want pi-observational-memory to record after final.
184
+ xtalpi drift defaults to --run-kind full-suite so targeted one-off smoke
185
+ artifacts do not create expected case-set drift noise.
186
+
187
+ Examples:
188
+ pi-67 xtalpi health
189
+ pi-67 xtalpi smoke --quick
190
+ pi-67 xtalpi smoke --case until-done-continuation
191
+ pi-67 xtalpi trend --json
192
+ pi-67 xtalpi drift --json
193
+ pi-67 xtalpi stress --until-done
194
+ pi-67 xtalpi run
195
+ `);
196
+ }
package/src/lib/args.mjs CHANGED
@@ -6,14 +6,20 @@ const BOOL_GLOBALS = new Set(["json", "dry-run", "yes", "help", "no-remote"]);
6
6
  export function splitGlobalArgs(argv) {
7
7
  const globals = {};
8
8
  const rest = [];
9
+ let commandSeen = false;
9
10
  for (let index = 0; index < argv.length; index += 1) {
10
11
  const arg = argv[index];
12
+ if (commandSeen) {
13
+ rest.push(arg);
14
+ continue;
15
+ }
11
16
  if (arg === "-h") {
12
17
  globals.help = true;
13
18
  continue;
14
19
  }
15
20
  if (!arg.startsWith("--")) {
16
21
  rest.push(arg);
22
+ commandSeen = true;
17
23
  continue;
18
24
  }
19
25
 
@@ -48,7 +54,7 @@ export function parseCommandOptions(argv, spec = {}) {
48
54
 
49
55
  for (let index = 0; index < argv.length; index += 1) {
50
56
  const arg = argv[index];
51
- if (arg === "-h") {
57
+ if (arg === "-h" || arg === "--help") {
52
58
  options.help = true;
53
59
  continue;
54
60
  }
@@ -1,6 +1,7 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
+ import { CliError } from "./output.mjs";
4
5
 
5
6
  export function inventorySkills(ctx) {
6
7
  const sourceRoot = path.join(ctx.repoRoot, "shared-skills");
@@ -29,17 +30,105 @@ export function inventorySkills(ctx) {
29
30
  };
30
31
  }
31
32
 
32
- export function syncSkills(ctx, { dryRun = false } = {}) {
33
+ export function diffSkill(ctx, name) {
33
34
  const inventory = inventorySkills(ctx);
35
+ const entry = inventory.entries.find((item) => item.name === name);
36
+ if (!entry) {
37
+ throw new CliError(`unknown shared skill: ${name}`, 2);
38
+ }
39
+ const sourceFiles = fileManifest(entry.source);
40
+ const targetFiles = entry.targetExists ? fileManifest(entry.target) : new Map();
41
+ const sourceNames = new Set(sourceFiles.keys());
42
+ const targetNames = new Set(targetFiles.keys());
43
+ const added = [...sourceNames].filter((rel) => !targetNames.has(rel)).sort();
44
+ const removed = [...targetNames].filter((rel) => !sourceNames.has(rel)).sort();
45
+ const modified = [...sourceNames]
46
+ .filter((rel) => targetNames.has(rel) && sourceFiles.get(rel).sha256 !== targetFiles.get(rel).sha256)
47
+ .sort();
48
+ return {
49
+ schema: "pi67.skills-diff.v1",
50
+ name,
51
+ source: entry.source,
52
+ target: entry.target,
53
+ sourceHash: entry.sourceHash,
54
+ targetExists: entry.targetExists,
55
+ targetHash: entry.targetHash,
56
+ identical: entry.identical,
57
+ conflict: entry.conflict,
58
+ diff: {
59
+ added,
60
+ removed,
61
+ modified,
62
+ sourceFileCount: sourceFiles.size,
63
+ targetFileCount: targetFiles.size,
64
+ },
65
+ };
66
+ }
67
+
68
+ export function planSkills(ctx, { names = [] } = {}) {
69
+ const inventory = inventorySkills(ctx);
70
+ const selected = normalizeNames(names, inventory);
71
+ const entries = selected.length === 0
72
+ ? inventory.entries.filter((entry) => !entry.identical)
73
+ : inventory.entries.filter((entry) => selected.includes(entry.name) && !entry.identical);
74
+ const actions = entries.map((entry) => ({
75
+ name: entry.name,
76
+ source: entry.source,
77
+ target: entry.target,
78
+ targetExists: entry.targetExists,
79
+ conflict: entry.conflict,
80
+ action: entry.conflict ? "preserve-conflict" : "copy-missing",
81
+ reason: entry.conflict
82
+ ? "target differs; default update preserves the existing global skill"
83
+ : "target is missing and can be copied safely",
84
+ }));
85
+ return {
86
+ schema: "pi67.skills-plan.v1",
87
+ sourceRoot: inventory.sourceRoot,
88
+ skillsDir: inventory.skillsDir,
89
+ selected,
90
+ summary: inventory.summary,
91
+ actions,
92
+ };
93
+ }
94
+
95
+ export function syncSkills(ctx, { dryRun = false, names = [], yes = false } = {}) {
96
+ const inventory = inventorySkills(ctx);
97
+ const selected = normalizeNames(names, inventory);
98
+ const selectedSet = new Set(selected);
99
+ const targeted = selected.length > 0;
34
100
  const actions = [];
35
101
  fs.mkdirSync(ctx.skillsDir, { recursive: true });
36
102
  for (const entry of inventory.entries) {
103
+ if (targeted && !selectedSet.has(entry.name)) continue;
37
104
  if (entry.identical) {
38
105
  actions.push({ name: entry.name, action: "skip", reason: "identical" });
39
106
  continue;
40
107
  }
41
108
  if (entry.conflict) {
42
- actions.push({ name: entry.name, action: "warn", reason: "target differs; preserved" });
109
+ if (!targeted || !yes) {
110
+ actions.push({
111
+ name: entry.name,
112
+ action: "warn",
113
+ reason: targeted
114
+ ? "target differs; rerun with --yes to replace this explicitly named skill after backup"
115
+ : "target differs; bulk conflict overwrite is intentionally blocked",
116
+ });
117
+ continue;
118
+ }
119
+ const backupDir = path.join(ctx.stateDir, "backups", `${timestamp()}-skills-sync`, entry.name);
120
+ actions.push({
121
+ name: entry.name,
122
+ action: dryRun ? "replace-dry-run" : "replace",
123
+ reason: "target differs and was explicitly named with --yes",
124
+ backupDir,
125
+ });
126
+ if (!dryRun) {
127
+ fs.mkdirSync(path.dirname(backupDir), { recursive: true, mode: 0o700 });
128
+ fs.cpSync(entry.target, backupDir, { recursive: true, force: true });
129
+ fs.rmSync(entry.target, { recursive: true, force: true });
130
+ fs.cpSync(entry.source, entry.target, { recursive: true, errorOnExist: true });
131
+ }
43
132
  continue;
44
133
  }
45
134
  actions.push({ name: entry.name, action: dryRun ? "copy-dry-run" : "copy", reason: "missing" });
@@ -47,7 +136,7 @@ export function syncSkills(ctx, { dryRun = false } = {}) {
47
136
  fs.cpSync(entry.source, entry.target, { recursive: true, errorOnExist: true });
48
137
  }
49
138
  }
50
- return { ...inventory, actions };
139
+ return { ...inventory, selected, actions };
51
140
  }
52
141
 
53
142
  function listSkillDirs(root) {
@@ -73,6 +162,44 @@ function hashDir(root) {
73
162
  return hash.digest("hex");
74
163
  }
75
164
 
165
+ function fileManifest(root) {
166
+ const result = new Map();
167
+ if (!fs.existsSync(root)) return result;
168
+ const files = [];
169
+ walk(root, files);
170
+ for (const file of files.sort()) {
171
+ const rel = path.relative(root, file).replace(/\\/g, "/");
172
+ const stat = fs.statSync(file);
173
+ result.set(rel, {
174
+ path: rel,
175
+ bytes: stat.size,
176
+ sha256: sha256File(file),
177
+ });
178
+ }
179
+ return result;
180
+ }
181
+
182
+ function sha256File(file) {
183
+ const hash = crypto.createHash("sha256");
184
+ hash.update(fs.readFileSync(file));
185
+ return hash.digest("hex");
186
+ }
187
+
188
+ function normalizeNames(names, inventory) {
189
+ const selected = [...new Set((names || []).filter(Boolean).map(String))];
190
+ const known = new Set(inventory.entries.map((entry) => entry.name));
191
+ for (const name of selected) {
192
+ if (!known.has(name)) {
193
+ throw new CliError(`unknown shared skill: ${name}`, 2);
194
+ }
195
+ }
196
+ return selected;
197
+ }
198
+
199
+ function timestamp() {
200
+ return new Date().toISOString().replace(/[-:]/g, "").replace(/\..+$/, "Z");
201
+ }
202
+
76
203
  function walk(dir, files) {
77
204
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
78
205
  const full = path.join(dir, entry.name);
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { gitStatus, remoteHead } from "./git.mjs";
3
+ import { gitStatus, gitText, remoteHead } from "./git.mjs";
4
4
  import { currentTheme, hasTheme, listThemes } from "./theme-policy.mjs";
5
5
  import { readJsonFileIfExists } from "./config-json.mjs";
6
6
  import { listExternal } from "./external-repos.mjs";
@@ -39,6 +39,8 @@ export async function buildUpdatePlan(ctx, options = {}) {
39
39
  currentVersion: pkg.version,
40
40
  noRemote: options.noRemote,
41
41
  });
42
+ const dirtyClass = classifyGitShort(git?.short || "");
43
+ const benignRuntime = classifyBenignRuntimeDiff(ctx, dirtyClass.preservedRuntime);
42
44
 
43
45
  const recommendations = [];
44
46
  if (managerRegistry.outdated) {
@@ -49,8 +51,13 @@ export async function buildUpdatePlan(ctx, options = {}) {
49
51
  recommendations.push("Run: pi-67 install");
50
52
  } else if (!git?.isRepo) {
51
53
  recommendations.push("Agent dir exists but is not a git checkout; inspect before installing.");
52
- } else if (git.dirty) {
54
+ } else if (git.dirty && dirtyClass.unsafeTracked.length > 0) {
53
55
  recommendations.push("Resolve or commit local changes before pi-67 update.");
56
+ } else if (git.dirty && benignRuntime.benign) {
57
+ recommendations.push("No manual action required for benign settings runtime markers; pi-67 can preserve them during update.");
58
+ recommendations.push("Optional: normalize local settings runtime marker if you want a clean git status.");
59
+ } else if (git.dirty) {
60
+ recommendations.push("No manual action required for user runtime config; pi-67 backs up/restores it during update.");
54
61
  } else {
55
62
  recommendations.push("Run: pi-67 update");
56
63
  }
@@ -66,7 +73,9 @@ export async function buildUpdatePlan(ctx, options = {}) {
66
73
  const decisions = buildPlanDecisions({
67
74
  ctx,
68
75
  git,
76
+ benignRuntime,
69
77
  managerRegistry,
78
+ remote,
70
79
  manifest,
71
80
  skills,
72
81
  external,
@@ -129,6 +138,7 @@ export function buildPlanDecisions(context) {
129
138
  ...new Set([...(context.manifest.runtimeFiles?.preserve || []), ...PRESERVED_RUNTIME_FILES]),
130
139
  ];
131
140
  const dirty = classifyGitShort(context.git?.short || "");
141
+ const benignRuntime = context.benignRuntime || { benign: false, reasons: [] };
132
142
 
133
143
  if (context.managerRegistry.outdated) {
134
144
  actions.push({
@@ -158,16 +168,36 @@ export function buildPlanDecisions(context) {
158
168
  recovery: "commit/stash intentional changes or rerun the script-level updater with an explicit dirty override",
159
169
  });
160
170
  } else if (context.git.dirty && dirty.preservedRuntime.length > 0) {
171
+ const remoteStatus = classifyIncomingRemoteStatus(context.git, context.remote);
172
+ const preserveInPlace = remoteStatus.upToDate;
161
173
  actions.push({
162
174
  id: "user-runtime-config",
163
175
  kind: "runtime-config",
164
- operation: "backup-and-restore-during-update",
165
- writes: ["~/.pi/pi67/backups/<timestamp>-update"],
176
+ operation: preserveInPlace
177
+ ? "preserve-in-place-no-backup"
178
+ : "conditional-backup-if-incoming-update-touches-runtime-config",
179
+ writes: preserveInPlace
180
+ ? []
181
+ : ["~/.pi/pi67/backups/pre-update-runtime-* only if incoming update touches preserved runtime files"],
166
182
  preserves: dirty.preservedRuntime,
167
183
  risk: "low",
168
- reason: "only user-owned runtime config files are dirty; update snapshots and restores them instead of overwriting",
184
+ reason: runtimeConfigActionReason({ benignRuntime, preserveInPlace, remoteStatus }),
185
+ benign: benignRuntime.benign,
186
+ benignReasons: benignRuntime.reasons,
187
+ createsNewBackup: !preserveInPlace,
188
+ backupCondition: preserveInPlace
189
+ ? "none: remote already matches the local checkout"
190
+ : "only when fetched incoming changes overlap preserved runtime files",
169
191
  });
170
- warnings.push(`dirty user runtime config will be preserved across update: ${dirty.preservedRuntime.join(", ")}`);
192
+ warnings.push(
193
+ preserveInPlace
194
+ ? `dirty user runtime config will stay in place; current remote is already at the local commit: ${dirty.preservedRuntime.join(", ")}`
195
+ : (
196
+ benignRuntime.benign
197
+ ? `benign user runtime marker will be preserved; backup is conditional on incoming path overlap: ${dirty.preservedRuntime.join(", ")}`
198
+ : `dirty user runtime config will be preserved; backup is conditional on incoming path overlap: ${dirty.preservedRuntime.join(", ")}`
199
+ ),
200
+ );
171
201
  }
172
202
  if (context.git?.dirty && dirty.untracked.length > 0) {
173
203
  warnings.push(`untracked files are present and will be preserved unless Git reports a path collision: ${dirty.untracked.join(", ")}`);
@@ -272,6 +302,30 @@ export function buildPlanDecisions(context) {
272
302
  return { actions, blocked, warnings };
273
303
  }
274
304
 
305
+ function classifyIncomingRemoteStatus(git, remote) {
306
+ if (remote?.skipped) return { upToDate: false, known: false, reason: "remote check skipped" };
307
+ if (!remote?.commit || !git?.commit) return { upToDate: false, known: false, reason: remote?.message || "remote commit unknown" };
308
+ const local = String(git.commit);
309
+ const remoteCommit = String(remote.commit);
310
+ return {
311
+ upToDate: remoteCommit.startsWith(local),
312
+ known: true,
313
+ reason: remoteCommit.startsWith(local)
314
+ ? "remote already matches local commit"
315
+ : "remote differs from local commit; updater will inspect changed paths after fetch",
316
+ };
317
+ }
318
+
319
+ function runtimeConfigActionReason({ benignRuntime, preserveInPlace, remoteStatus }) {
320
+ const prefix = benignRuntime.benign
321
+ ? `benign runtime marker only: ${benignRuntime.reasons.join("; ")}`
322
+ : "only user-owned runtime config files are dirty";
323
+ if (preserveInPlace) {
324
+ return `${prefix}; ${remoteStatus.reason}; no runtime backup is needed`;
325
+ }
326
+ return `${prefix}; updater fetches first and creates a runtime backup only if incoming changes touch these preserved files`;
327
+ }
328
+
275
329
  export function classifyGitShort(short) {
276
330
  const preserved = new Set(PRESERVED_RUNTIME_FILES);
277
331
  const result = {
@@ -298,6 +352,36 @@ export function classifyGitShort(short) {
298
352
  return result;
299
353
  }
300
354
 
355
+ export function classifyBenignRuntimeDiff(ctx, preservedRuntimePaths = []) {
356
+ const paths = [...new Set(preservedRuntimePaths)].sort();
357
+ if (paths.length !== 1 || paths[0] !== "settings.json") {
358
+ return { benign: false, reasons: [] };
359
+ }
360
+ const diff = gitText(ctx.repoRoot, ["diff", "--", "settings.json"]);
361
+ if (!diff) return { benign: false, reasons: [] };
362
+ const changed = diff.split(/\r?\n/).filter((line) =>
363
+ (line.startsWith("+") || line.startsWith("-")) &&
364
+ !line.startsWith("+++") &&
365
+ !line.startsWith("---"));
366
+ const meaningful = changed.filter((line) => {
367
+ const body = line.slice(1).trim();
368
+ return body !== "" && body !== "}";
369
+ });
370
+ const reasons = [];
371
+ const markerOnly = meaningful.length > 0 &&
372
+ meaningful.every((line) => /^[-+]\s*"lastChangelogVersion"\s*:/.test(line));
373
+ if (markerOnly) {
374
+ reasons.push("settings.json lastChangelogVersion runtime marker changed");
375
+ }
376
+ if (changed.some((line) => ["-", "+", "-}", "+}"].includes(line.trim()))) {
377
+ reasons.push("settings.json trailing newline state changed");
378
+ }
379
+ return {
380
+ benign: markerOnly || (meaningful.length === 0 && reasons.length > 0),
381
+ reasons,
382
+ };
383
+ }
384
+
301
385
  function parseStatusPath(line) {
302
386
  let file = "";
303
387
  if (line.length >= 3 && line[2] === " ") {
@@ -18,16 +18,21 @@ const LOCK_STALE_AFTER_MS = 4 * 60 * 60 * 1000;
18
18
  export function beginUpdateLifecycle(ctx, options = {}) {
19
19
  const operation = options.operation || "update";
20
20
  const dryRun = Boolean(options.dryRun);
21
+ const backupRuntime = options.backupRuntime !== false;
21
22
  const lockPath = path.join(ctx.stateDir, "locks", "update.lock");
22
23
  const backupDir = path.join(ctx.stateDir, "backups", `${timestamp()}-${operation}`);
23
24
 
24
25
  if (dryRun) {
25
26
  info(`DRY-RUN would acquire update lock: ${lockPath}`);
26
- info(`DRY-RUN would snapshot preserved runtime files into: ${backupDir}`);
27
+ if (backupRuntime) {
28
+ info(`DRY-RUN would snapshot preserved runtime files into: ${backupDir}`);
29
+ }
27
30
  return {
28
31
  lockPath,
29
32
  backupDir,
30
33
  backedUp: [],
34
+ backupSkipped: !backupRuntime,
35
+ backupReason: backupRuntime ? "" : "runtime backup is delegated to the updater script when needed",
31
36
  release() {},
32
37
  };
33
38
  }
@@ -35,6 +40,21 @@ export function beginUpdateLifecycle(ctx, options = {}) {
35
40
  acquireLock(lockPath, { operation });
36
41
  let released = false;
37
42
  try {
43
+ if (!backupRuntime) {
44
+ return {
45
+ lockPath,
46
+ backupDir: "",
47
+ backedUp: [],
48
+ backupSkipped: true,
49
+ backupReason: "runtime backup is delegated to the updater script when needed",
50
+ reusedBackupDir: "",
51
+ release() {
52
+ if (released) return;
53
+ released = true;
54
+ releaseLock(lockPath);
55
+ },
56
+ };
57
+ }
38
58
  const backup = createRuntimeBackup(ctx, backupDir, {
39
59
  operation,
40
60
  plan: options.plan,