@bigking67/pi-67 0.10.6 → 0.10.7

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,15 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.10.7]
4
+
5
+ - Migrates `settings.json.lastChangelogVersion` into ignored manager state at
6
+ `~/.pi/pi67/state.json`, normalizes the runtime-only marker out of
7
+ `settings.json`, and installs a local Git clean filter so future marker
8
+ writes cannot be carried into normal diffs or commits.
9
+ - Renames shared-skill drift in human-facing output from "conflicts" to
10
+ "preserved user-modified" while keeping the JSON `conflicts` compatibility
11
+ field.
12
+
3
13
  ## [0.10.6]
4
14
 
5
15
  - Adds runtime request retry to the canonical `xtalpi-pi-tools` provider path,
package/README.md CHANGED
@@ -129,8 +129,9 @@ The explicit theme setter also writes a runtime backup before changing
129
129
  `settings.json`; normal update never changes the selected theme.
130
130
 
131
131
  The manager writes lightweight state outside the repo at `~/.pi/pi67/state.json`.
132
- It records versions, paths, theme, provider/model, and commit information; it
133
- does not store API keys.
132
+ It records versions, paths, theme, provider/model, commit information, and
133
+ runtime-only UI markers such as `settings.json.lastChangelogVersion` after
134
+ migrating them out of tracked config; it does not store API keys.
134
135
 
135
136
  ## Main commands
136
137
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bigking67/pi-67",
3
- "version": "0.10.6",
3
+ "version": "0.10.7",
4
4
  "description": "Cross-platform manager CLI for the pi-67 Pi Coding Agent distribution.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,6 +25,32 @@
25
25
  },
26
26
  "lastTheme": {
27
27
  "type": "string"
28
+ },
29
+ "runtimeMarkers": {
30
+ "type": "object",
31
+ "description": "Runtime-only UI markers migrated out of tracked runtime config files.",
32
+ "properties": {
33
+ "lastChangelogVersion": {
34
+ "type": "object",
35
+ "required": ["value", "storage"],
36
+ "properties": {
37
+ "value": {
38
+ "type": "string"
39
+ },
40
+ "source": {
41
+ "type": "string"
42
+ },
43
+ "storage": {
44
+ "type": "string"
45
+ },
46
+ "updatedAt": {
47
+ "type": "string"
48
+ }
49
+ },
50
+ "additionalProperties": true
51
+ }
52
+ },
53
+ "additionalProperties": true
28
54
  }
29
55
  },
30
56
  "additionalProperties": true
package/scripts/check.mjs CHANGED
@@ -8,6 +8,11 @@ import { commandCandidatesForPlatform } from "../src/lib/shell-runner.mjs";
8
8
  import { parseCommandOptions, splitGlobalArgs } from "../src/lib/args.mjs";
9
9
  import { readExtensionRegistry, validateExtensionRegistry } from "../src/lib/extension-registry.mjs";
10
10
  import { buildPlanDecisions, classifyGitShort } from "../src/lib/update-plan.mjs";
11
+ import {
12
+ mergeSettingsRuntimeMarkerIntoState,
13
+ settingsRuntimeMarkerFromObject,
14
+ stripSettingsRuntimeMarkerText,
15
+ } from "../src/lib/settings-runtime-state.mjs";
11
16
  import {
12
17
  beginUpdateLifecycle,
13
18
  inspectLegacyConflictBackup,
@@ -35,6 +40,7 @@ runCliHelpContractSelfTests();
35
40
  runPublishTargetSelfTests();
36
41
  runShellRunnerSelfTests();
37
42
  runExtensionRegistrySelfTests();
43
+ runSettingsRuntimeStateSelfTests();
38
44
  runUpdatePlanSelfTests();
39
45
  runUpdateSafetySelfTests();
40
46
 
@@ -322,6 +328,22 @@ function runExtensionRegistrySelfTests() {
322
328
  );
323
329
  }
324
330
 
331
+ function runSettingsRuntimeStateSelfTests() {
332
+ const input = "{\n \"lastChangelogVersion\": \"0.80.3\",\n \"theme\": \"gruvbox-dark\"\n}\n";
333
+ const stripped = JSON.parse(stripSettingsRuntimeMarkerText(input));
334
+ assert(
335
+ stripped.lastChangelogVersion === undefined && stripped.theme === "gruvbox-dark",
336
+ "settings runtime clean filter must remove only lastChangelogVersion",
337
+ );
338
+ const marker = settingsRuntimeMarkerFromObject({ lastChangelogVersion: "0.80.3" });
339
+ const state = mergeSettingsRuntimeMarkerIntoState({ schema: "pi67.state.v1" }, marker, "2026-07-08T00:00:00.000Z");
340
+ assert(
341
+ state.runtimeMarkers?.lastChangelogVersion?.value === "0.80.3" &&
342
+ state.runtimeMarkers.lastChangelogVersion.storage === "state.json",
343
+ "settings runtime marker must migrate into state runtimeMarkers",
344
+ );
345
+ }
346
+
325
347
  function runUpdatePlanSelfTests() {
326
348
  assert(
327
349
  classifyGitShort(" M settings.json\n?? tmp.txt").preservedRuntime.includes("settings.json"),
@@ -396,8 +418,8 @@ function runUpdatePlanSelfTests() {
396
418
  skills: { summary: { missing: 0, conflicts: 2 } },
397
419
  });
398
420
  assert(
399
- buildPlanDecisions(sharedSkillConflict).warnings.some((item) => item.includes("preserves existing different skills")),
400
- "shared skill conflicts must warn and preserve by default",
421
+ buildPlanDecisions(sharedSkillConflict).warnings.some((item) => item.includes("preserved user-modified global skills")),
422
+ "preserved user-modified shared skills must warn and preserve by default",
401
423
  );
402
424
  const strictSharedSkillConflict = decisionsFixture({
403
425
  strictSharedSkills: true,
@@ -42,7 +42,7 @@ Options:
42
42
  --no-skill-list Skip pi skill list on POSIX platforms; accepted as a no-op on Windows.
43
43
  --skill-list-timeout-seconds N
44
44
  Timeout for pi skill list where enabled.
45
- --strict-shared-skills Treat differing shared skills as blocking.
45
+ --strict-shared-skills Treat preserved user-modified shared skills as blocking.
46
46
  --dry-run Print the script invocation without running it.
47
47
 
48
48
  Examples:
@@ -7,6 +7,7 @@ import { runDistroScript } from "../lib/distro-scripts.mjs";
7
7
  import { isWindows } from "../lib/platform.mjs";
8
8
  import { CliError, info, warn } from "../lib/output.mjs";
9
9
  import { writeState } from "../lib/state-store.mjs";
10
+ import { migrateSettingsRuntimeState } from "../lib/settings-runtime-state.mjs";
10
11
 
11
12
  export async function installCommand(ctx, argv) {
12
13
  const { options } = parseCommandOptions(argv, {
@@ -47,7 +48,25 @@ export async function installCommand(ctx, argv) {
47
48
  if (dryRun) args.push("--dry-run");
48
49
  runCommand("bash", [path.join(ctx.repoRoot, "install.sh"), ...args], { cwd: ctx.repoRoot, dryRun: false });
49
50
  }
50
- if (!dryRun) writeState(ctx, "install");
51
+ if (!dryRun) {
52
+ writeState(ctx, "install");
53
+ const runtimeState = migrateSettingsRuntimeState(ctx, {
54
+ normalizeSettingsJson: true,
55
+ installGitFilter: true,
56
+ });
57
+ if (runtimeState.markerFound && runtimeState.stateWritten) {
58
+ info("Migrated settings.json lastChangelogVersion to ignored state: ~/.pi/pi67/state.json");
59
+ }
60
+ if (runtimeState.settingsNormalized) {
61
+ info("Normalized settings.json by removing runtime-only lastChangelogVersion.");
62
+ }
63
+ if (runtimeState.gitFilterInstalled) {
64
+ info("Installed local git clean filter for future settings.json runtime markers.");
65
+ }
66
+ for (const error of runtimeState.errors) {
67
+ warn(`settings runtime marker migration skipped: ${error}`);
68
+ }
69
+ }
51
70
  info("Install finished. Run `pi-67 doctor` next.");
52
71
  }
53
72
 
@@ -26,9 +26,9 @@ function inventory(ctx, argv) {
26
26
  keyValue("Target", data.skillsDir);
27
27
  keyValue("Identical", data.summary.identical);
28
28
  keyValue("Missing", data.summary.missing);
29
- keyValue("Conflicts", data.summary.conflicts);
29
+ keyValue("Preserved user-modified", preservedUserModified(data.summary));
30
30
  for (const entry of data.entries.filter((item) => !item.identical)) {
31
- const status = entry.conflict ? "conflict" : "missing";
31
+ const status = entry.conflict ? "preserved user-modified" : "missing";
32
32
  info(`${status}: ${entry.name}`);
33
33
  }
34
34
  }
@@ -43,7 +43,7 @@ function plan(ctx, argv) {
43
43
  keyValue("Target", data.skillsDir);
44
44
  keyValue("Selected", data.selected.length === 0 ? "all changed skills" : data.selected.join(", "));
45
45
  keyValue("Missing", data.actions.filter((item) => item.action === "copy-missing").length);
46
- keyValue("Conflicts", data.actions.filter((item) => item.action === "preserve-conflict").length);
46
+ keyValue("Preserved user-modified", data.actions.filter((item) => item.action === "preserve-conflict").length);
47
47
  for (const action of data.actions) {
48
48
  const command = action.conflict
49
49
  ? `inspect: pi-67 skills diff ${action.name}; explicit sync: pi-67 skills sync ${action.name} --dry-run`
@@ -100,7 +100,11 @@ function migrate(ctx, argv) {
100
100
  section("pi-67 skills migrate preview");
101
101
  warn("migrate is intentionally dry-run in the npm manager; use `pi-67 skills sync` to copy missing skills.");
102
102
  keyValue("Missing", data.summary.missing);
103
- keyValue("Conflicts", data.summary.conflicts);
103
+ keyValue("Preserved user-modified", preservedUserModified(data.summary));
104
+ }
105
+
106
+ function preservedUserModified(summary) {
107
+ return summary?.preservedUserModified ?? summary?.conflicts ?? 0;
104
108
  }
105
109
 
106
110
  function printSkillsHelp() {
@@ -115,8 +119,9 @@ Usage:
115
119
 
116
120
  Safety:
117
121
  Missing skills are copied by default. Existing different global skills are
118
- preserved unless you name the skill explicitly and pass --yes. Bulk conflict
119
- overwrite is intentionally blocked.
122
+ preserved as user-modified unless you name the skill explicitly and pass
123
+ --yes. Bulk overwrite of preserved user-modified skills is intentionally
124
+ blocked.
120
125
 
121
126
  Examples:
122
127
  pi-67 skills inventory
@@ -21,7 +21,11 @@ export async function statusCommand(ctx, argv) {
21
21
  keyValue("Provider", plan.settings.defaultProvider || "unset");
22
22
  keyValue("Model", plan.settings.defaultModel || "unset");
23
23
  keyValue("Theme", plan.settings.theme || "unset");
24
- keyValue("Shared skills", `${plan.skills.identical} ok, ${plan.skills.missing} missing, ${plan.skills.conflicts} conflicts`);
24
+ keyValue("Shared skills", `${plan.skills.identical} ok, ${plan.skills.missing} missing, ${preservedUserModified(plan.skills)} preserved user-modified`);
25
+ }
26
+
27
+ function preservedUserModified(skills) {
28
+ return skills?.preservedUserModified ?? skills?.conflicts ?? 0;
25
29
  }
26
30
 
27
31
  function printStatusHelp() {
@@ -6,6 +6,7 @@ import { isWindows } from "../lib/platform.mjs";
6
6
  import { runCommand } from "../lib/shell-runner.mjs";
7
7
  import { writeState } from "../lib/state-store.mjs";
8
8
  import { beginUpdateLifecycle } from "../lib/update-safety.mjs";
9
+ import { migrateSettingsRuntimeState } from "../lib/settings-runtime-state.mjs";
9
10
 
10
11
  export async function updateCommand(ctx, argv) {
11
12
  const { options } = parseCommandOptions(argv, {
@@ -65,7 +66,10 @@ export async function updateCommand(ctx, argv) {
65
66
  : buildBashUpdateArgs(ctx, options, dryRun);
66
67
  try {
67
68
  runDistroScript(ctx, { sh: "pi67-update.sh", ps1: "pi67-update.ps1" }, args, { dryRun: false });
68
- if (!dryRun) writeState(ctx, options.repair ? "repair" : "update");
69
+ if (!dryRun) {
70
+ writeState(ctx, options.repair ? "repair" : "update");
71
+ reportSettingsRuntimeStateMigration(ctx);
72
+ }
69
73
  } finally {
70
74
  lifecycle.release();
71
75
  }
@@ -82,6 +86,25 @@ export async function updateCommand(ctx, argv) {
82
86
  }
83
87
  }
84
88
 
89
+ function reportSettingsRuntimeStateMigration(ctx) {
90
+ const result = migrateSettingsRuntimeState(ctx, {
91
+ normalizeSettingsJson: true,
92
+ installGitFilter: true,
93
+ });
94
+ if (result.markerFound && result.stateWritten) {
95
+ info("Migrated settings.json lastChangelogVersion to ignored state: ~/.pi/pi67/state.json");
96
+ }
97
+ if (result.settingsNormalized) {
98
+ info("Normalized settings.json by removing runtime-only lastChangelogVersion.");
99
+ }
100
+ if (result.gitFilterInstalled) {
101
+ info("Installed local git clean filter for future settings.json runtime markers.");
102
+ }
103
+ for (const error of result.errors) {
104
+ warn(`settings runtime marker migration skipped: ${error}`);
105
+ }
106
+ }
107
+
85
108
  function buildBashUpdateArgs(ctx, options, dryRun) {
86
109
  const args = ["--agent-dir", ctx.agentDir, "--repo-root", ctx.repoRoot, "--skills-dir", ctx.skillsDir];
87
110
  if (dryRun) args.push("--dry-run");
@@ -128,7 +151,7 @@ function printPlan(plan) {
128
151
  keyValue("Model", plan.settings.defaultModel || "unset");
129
152
  keyValue("Theme", plan.settings.theme || "unset");
130
153
  keyValue("Theme installed", plan.settings.themeInstalled ? "yes" : "no");
131
- keyValue("Shared skills", `${plan.skills.identical} ok, ${plan.skills.missing} missing, ${plan.skills.conflicts} conflicts`);
154
+ keyValue("Shared skills", `${plan.skills.identical} ok, ${plan.skills.missing} missing, ${preservedUserModified(plan.skills)} preserved user-modified`);
132
155
  for (const repo of plan.external) {
133
156
  keyValue(`External ${repo.name}`, repo.exists ? `${repo.git?.dirty ? "dirty" : "clean"} ${repo.git?.commit || ""}` : "missing");
134
157
  }
@@ -153,6 +176,10 @@ function printPlan(plan) {
153
176
  if (!plan.git?.dirty) pass("update check completed without local dirty blocker");
154
177
  }
155
178
 
179
+ function preservedUserModified(skills) {
180
+ return skills?.preservedUserModified ?? skills?.conflicts ?? 0;
181
+ }
182
+
156
183
  function printUpdateHelp() {
157
184
  process.stdout.write(`pi-67 update - update pi-67 safely
158
185
 
@@ -167,7 +194,7 @@ Options:
167
194
  --no-remote Skip remote git/npm registry checks where supported.
168
195
  --no-npm Skip npm package sync in the distro updater.
169
196
  --allow-dirty Let the script-level updater handle non-runtime dirty files.
170
- --strict-shared-skills Treat differing shared skills as blocking.
197
+ --strict-shared-skills Treat preserved user-modified shared skills as blocking.
171
198
  --include-pi Also run upstream \`pi update --all\` when paired with --yes.
172
199
  --include-external Report explicit external repo update commands.
173
200
  --all Alias for --include-pi --include-external.
@@ -0,0 +1,20 @@
1
+ export const SETTINGS_RUNTIME_MARKER_KEY = "lastChangelogVersion";
2
+
3
+ export function stripSettingsRuntimeMarker(value) {
4
+ if (!value || typeof value !== "object" || Array.isArray(value)) return value;
5
+ const next = { ...value };
6
+ delete next[SETTINGS_RUNTIME_MARKER_KEY];
7
+ return next;
8
+ }
9
+
10
+ export function stripSettingsRuntimeMarkerText(text) {
11
+ try {
12
+ return stableSettingsJson(stripSettingsRuntimeMarker(JSON.parse(String(text || "").replace(/^\uFEFF/, ""))));
13
+ } catch {
14
+ return String(text || "");
15
+ }
16
+ }
17
+
18
+ export function stableSettingsJson(value) {
19
+ return `${JSON.stringify(value, null, 2)}\n`;
20
+ }
@@ -0,0 +1,167 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { captureCommand } from "./shell-runner.mjs";
4
+ import { isGitRepo } from "./git.mjs";
5
+ import { readJsonFileIfExists, writeJsonAtomic } from "./config-json.mjs";
6
+ import {
7
+ SETTINGS_RUNTIME_MARKER_KEY,
8
+ stableSettingsJson,
9
+ stripSettingsRuntimeMarker,
10
+ stripSettingsRuntimeMarkerText,
11
+ } from "./settings-runtime-clean.mjs";
12
+
13
+ export const SETTINGS_RUNTIME_FILTER_NAME = "pi67-settings-runtime-state";
14
+ export const SETTINGS_RUNTIME_FILTER_SCRIPT = "packages/pi67-cli/src/tools/settings-runtime-state-filter.mjs";
15
+
16
+ export { SETTINGS_RUNTIME_MARKER_KEY, stripSettingsRuntimeMarker, stripSettingsRuntimeMarkerText };
17
+
18
+ export function settingsRuntimeMarkerFromObject(settings) {
19
+ if (!settings || typeof settings !== "object" || Array.isArray(settings)) return null;
20
+ const value = settings[SETTINGS_RUNTIME_MARKER_KEY];
21
+ if (value === undefined || value === null || String(value).trim() === "") return null;
22
+ return {
23
+ key: SETTINGS_RUNTIME_MARKER_KEY,
24
+ value: String(value),
25
+ };
26
+ }
27
+
28
+ export function settingsRuntimeMarkerFromState(state) {
29
+ const marker = state?.runtimeMarkers?.[SETTINGS_RUNTIME_MARKER_KEY];
30
+ if (!marker || marker.value === undefined || marker.value === null || String(marker.value).trim() === "") {
31
+ return null;
32
+ }
33
+ return {
34
+ key: SETTINGS_RUNTIME_MARKER_KEY,
35
+ value: String(marker.value),
36
+ };
37
+ }
38
+
39
+ export function mergeSettingsRuntimeMarkerIntoState(state, marker, now = new Date().toISOString()) {
40
+ const next = { ...(state || {}) };
41
+ const runtimeMarkers = { ...(next.runtimeMarkers || {}) };
42
+ if (marker?.value) {
43
+ runtimeMarkers[SETTINGS_RUNTIME_MARKER_KEY] = {
44
+ value: marker.value,
45
+ source: "settings.json",
46
+ storage: "state.json",
47
+ updatedAt: now,
48
+ };
49
+ }
50
+ if (Object.keys(runtimeMarkers).length > 0) {
51
+ next.runtimeMarkers = runtimeMarkers;
52
+ }
53
+ return next;
54
+ }
55
+
56
+ export function migrateSettingsRuntimeState(ctx, options = {}) {
57
+ const normalizeSettingsJson = Boolean(options.normalizeSettingsJson);
58
+ const installGitFilter = Boolean(options.installGitFilter);
59
+ const dryRun = Boolean(options.dryRun);
60
+ const settingsPath = path.join(ctx.agentDir, "settings.json");
61
+ const statePath = path.join(ctx.stateDir, "state.json");
62
+ const result = {
63
+ schema: "pi67.settings-runtime-state.v1",
64
+ settingsPath,
65
+ statePath,
66
+ markerFound: false,
67
+ markerValue: "",
68
+ stateWritten: false,
69
+ settingsNormalized: false,
70
+ gitFilterInstalled: false,
71
+ skipped: [],
72
+ errors: [],
73
+ };
74
+
75
+ if (!fs.existsSync(settingsPath)) {
76
+ result.skipped.push("settings.json missing");
77
+ } else {
78
+ let settingsText = "";
79
+ let settings = null;
80
+ try {
81
+ settingsText = fs.readFileSync(settingsPath, "utf8").replace(/^\uFEFF/, "");
82
+ settings = JSON.parse(settingsText);
83
+ } catch (error) {
84
+ result.errors.push(`settings.json is not valid JSON: ${error.message}`);
85
+ }
86
+
87
+ const marker = settingsRuntimeMarkerFromObject(settings);
88
+ if (marker) {
89
+ result.markerFound = true;
90
+ result.markerValue = marker.value;
91
+ if (!dryRun) {
92
+ const previous = readJsonFileIfExists(statePath) || {};
93
+ writeJsonAtomic(statePath, mergeSettingsRuntimeMarkerIntoState(previous, marker));
94
+ result.stateWritten = true;
95
+ }
96
+ if (normalizeSettingsJson) {
97
+ const normalizedText = stableSettingsJson(stripSettingsRuntimeMarker(settings));
98
+ if (normalizedText !== settingsText) {
99
+ if (!dryRun) {
100
+ fs.writeFileSync(settingsPath, normalizedText, "utf8");
101
+ }
102
+ result.settingsNormalized = true;
103
+ }
104
+ }
105
+ } else {
106
+ result.skipped.push("settings.json runtime marker absent");
107
+ }
108
+ }
109
+
110
+ if (installGitFilter) {
111
+ const filterResult = installSettingsRuntimeGitFilter(ctx, { dryRun });
112
+ result.gitFilterInstalled = filterResult.installed;
113
+ if (filterResult.skipped) result.skipped.push(filterResult.skipped);
114
+ if (filterResult.error) result.errors.push(filterResult.error);
115
+ }
116
+
117
+ return result;
118
+ }
119
+
120
+ export function installSettingsRuntimeGitFilter(ctx, options = {}) {
121
+ const result = {
122
+ installed: false,
123
+ skipped: "",
124
+ error: "",
125
+ };
126
+ if (options.dryRun) {
127
+ result.installed = true;
128
+ return result;
129
+ }
130
+ if (!isGitRepo(ctx.repoRoot)) {
131
+ result.skipped = "repo root is not a git checkout";
132
+ return result;
133
+ }
134
+ const scriptPath = path.join(ctx.repoRoot, SETTINGS_RUNTIME_FILTER_SCRIPT);
135
+ if (!fs.existsSync(scriptPath)) {
136
+ result.skipped = `settings runtime filter script missing: ${SETTINGS_RUNTIME_FILTER_SCRIPT}`;
137
+ return result;
138
+ }
139
+
140
+ const cleanCommand = `node ${SETTINGS_RUNTIME_FILTER_SCRIPT} --clean`;
141
+ const clean = captureCommand("git", [
142
+ "-C",
143
+ ctx.repoRoot,
144
+ "config",
145
+ "--local",
146
+ `filter.${SETTINGS_RUNTIME_FILTER_NAME}.clean`,
147
+ cleanCommand,
148
+ ]);
149
+ if (!clean.ok) {
150
+ result.error = clean.stderr || clean.error || "failed to configure settings runtime clean filter";
151
+ return result;
152
+ }
153
+ const required = captureCommand("git", [
154
+ "-C",
155
+ ctx.repoRoot,
156
+ "config",
157
+ "--local",
158
+ `filter.${SETTINGS_RUNTIME_FILTER_NAME}.required`,
159
+ "false",
160
+ ]);
161
+ if (!required.ok) {
162
+ result.error = required.stderr || required.error || "failed to configure settings runtime filter required=false";
163
+ return result;
164
+ }
165
+ result.installed = true;
166
+ return result;
167
+ }
@@ -16,6 +16,7 @@ export function inventorySkills(ctx) {
16
16
  const conflict = targetExists && !identical;
17
17
  return { name, source, target, sourceHash, targetExists, targetHash, identical, conflict };
18
18
  });
19
+ const conflicts = entries.filter((entry) => entry.conflict).length;
19
20
  return {
20
21
  schema: "pi67.skills-inventory.v1",
21
22
  sourceRoot,
@@ -24,7 +25,8 @@ export function inventorySkills(ctx) {
24
25
  source: entries.length,
25
26
  missing: entries.filter((entry) => !entry.targetExists).length,
26
27
  identical: entries.filter((entry) => entry.identical).length,
27
- conflicts: entries.filter((entry) => entry.conflict).length,
28
+ conflicts,
29
+ preservedUserModified: conflicts,
28
30
  },
29
31
  entries,
30
32
  };
@@ -79,7 +81,7 @@ export function planSkills(ctx, { names = [] } = {}) {
79
81
  conflict: entry.conflict,
80
82
  action: entry.conflict ? "preserve-conflict" : "copy-missing",
81
83
  reason: entry.conflict
82
- ? "target differs; default update preserves the existing global skill"
84
+ ? "target differs; default update preserves this user-modified global skill"
83
85
  : "target is missing and can be copied safely",
84
86
  }));
85
87
  return {
@@ -112,7 +114,7 @@ export function syncSkills(ctx, { dryRun = false, names = [], yes = false } = {}
112
114
  action: "warn",
113
115
  reason: targeted
114
116
  ? "target differs; rerun with --yes to replace this explicitly named skill after backup"
115
- : "target differs; bulk conflict overwrite is intentionally blocked",
117
+ : "target differs; bulk overwrite of preserved user-modified skills is intentionally blocked",
116
118
  });
117
119
  continue;
118
120
  }
@@ -4,11 +4,18 @@ import { gitStatus } from "./git.mjs";
4
4
  import { currentTheme } from "./theme-policy.mjs";
5
5
  import { readJsonFileIfExists, writeJsonAtomic } from "./config-json.mjs";
6
6
  import { readCliPackageJson, readTextIfExists } from "./paths.mjs";
7
+ import {
8
+ mergeSettingsRuntimeMarkerIntoState,
9
+ settingsRuntimeMarkerFromObject,
10
+ settingsRuntimeMarkerFromState,
11
+ } from "./settings-runtime-state.mjs";
7
12
 
8
13
  export function writeState(ctx, operation) {
9
14
  const pkg = readCliPackageJson();
10
15
  const git = gitStatus(ctx.repoRoot);
11
16
  const settings = readJsonFileIfExists(path.join(ctx.agentDir, "settings.json")) || {};
17
+ const statePath = path.join(ctx.stateDir, "state.json");
18
+ const previous = readJsonFileIfExists(statePath) || {};
12
19
  const state = {
13
20
  schema: "pi67.state.v1",
14
21
  updatedAt: new Date().toISOString(),
@@ -25,7 +32,12 @@ export function writeState(ctx, operation) {
25
32
  lastProvider: settings.defaultProvider || "",
26
33
  lastModel: settings.defaultModel || "",
27
34
  };
35
+ const mergedState = mergeSettingsRuntimeMarkerIntoState(
36
+ state,
37
+ settingsRuntimeMarkerFromObject(settings) || settingsRuntimeMarkerFromState(previous),
38
+ state.updatedAt,
39
+ );
28
40
  fs.mkdirSync(ctx.stateDir, { recursive: true });
29
- writeJsonAtomic(path.join(ctx.stateDir, "state.json"), state);
30
- return state;
41
+ writeJsonAtomic(statePath, mergedState);
42
+ return mergedState;
31
43
  }
@@ -9,6 +9,7 @@ import { readCliPackageJson, readTextIfExists } from "./paths.mjs";
9
9
  import { npmLatestVersion } from "./npm-registry.mjs";
10
10
  import { buildDistroManifest } from "./distro-manifest.mjs";
11
11
  import { PRESERVED_RUNTIME_FILES } from "./update-safety.mjs";
12
+ import { settingsRuntimeMarkerFromObject } from "./settings-runtime-state.mjs";
12
13
 
13
14
  export async function buildUpdatePlan(ctx, options = {}) {
14
15
  const versionFile = path.join(ctx.repoRoot, "VERSION");
@@ -39,6 +40,7 @@ export async function buildUpdatePlan(ctx, options = {}) {
39
40
  currentVersion: pkg.version,
40
41
  noRemote: options.noRemote,
41
42
  });
43
+ const settingsRuntimeMarker = settingsRuntimeMarkerFromObject(settings);
42
44
  const dirtyClass = classifyGitShort(git?.short || "");
43
45
  const benignRuntime = classifyBenignRuntimeDiff(ctx, dirtyClass.preservedRuntime);
44
46
 
@@ -54,15 +56,14 @@ export async function buildUpdatePlan(ctx, options = {}) {
54
56
  } else if (git.dirty && dirtyClass.unsafeTracked.length > 0) {
55
57
  recommendations.push("Resolve or commit local changes before pi-67 update.");
56
58
  } 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
+ recommendations.push("No manual action required for benign settings runtime markers; pi-67 migrates lastChangelogVersion to ignored state during update/repair.");
59
60
  } else if (git.dirty) {
60
61
  recommendations.push("No manual action required for user runtime config; pi-67 backs up/restores it during update.");
61
62
  } else {
62
63
  recommendations.push("Run: pi-67 update");
63
64
  }
64
65
  if (skills.summary.conflicts > 0) {
65
- recommendations.push("Run: pi-67 skills inventory and resolve differing global skills manually.");
66
+ recommendations.push("Run: pi-67 skills inventory to inspect preserved user-modified global skills.");
66
67
  }
67
68
  if (theme && !hasTheme(ctx, theme)) {
68
69
  recommendations.push(`Current theme is missing: ${theme}. Run: pi-67 themes list`);
@@ -74,6 +75,7 @@ export async function buildUpdatePlan(ctx, options = {}) {
74
75
  ctx,
75
76
  git,
76
77
  benignRuntime,
78
+ settingsRuntimeMarker,
77
79
  managerRegistry,
78
80
  remote,
79
81
  manifest,
@@ -122,6 +124,11 @@ export async function buildUpdatePlan(ctx, options = {}) {
122
124
  scripts: scriptStatus,
123
125
  manifest: manifest.summary,
124
126
  skills: skills.summary,
127
+ runtimeState: {
128
+ settingsLastChangelogVersion: settingsRuntimeMarker?.value || "",
129
+ storage: "~/.pi/pi67/state.json",
130
+ policy: "settings.json lastChangelogVersion is runtime-only and is normalized into ignored state on update/repair",
131
+ },
125
132
  external,
126
133
  actions: decisions.actions,
127
134
  blocked: decisions.blocked,
@@ -139,6 +146,7 @@ export function buildPlanDecisions(context) {
139
146
  ];
140
147
  const dirty = classifyGitShort(context.git?.short || "");
141
148
  const benignRuntime = context.benignRuntime || { benign: false, reasons: [] };
149
+ const settingsRuntimeMarker = context.settingsRuntimeMarker || null;
142
150
 
143
151
  if (context.managerRegistry.outdated) {
144
152
  actions.push({
@@ -153,6 +161,19 @@ export function buildPlanDecisions(context) {
153
161
  });
154
162
  }
155
163
 
164
+ if (settingsRuntimeMarker?.value || benignRuntime.benign) {
165
+ actions.push({
166
+ id: "settings-runtime-state",
167
+ kind: "runtime-state",
168
+ operation: "migrate-lastChangelogVersion-to-ignored-state",
169
+ writes: ["~/.pi/pi67/state.json", "settings.json only when removing runtime-only lastChangelogVersion"],
170
+ preserves: ["settings.json.theme", "settings.json.defaultProvider", "settings.json.defaultModel", "settings.json.packages"],
171
+ risk: "low",
172
+ reason: "lastChangelogVersion is Pi runtime UI state, not pi-67 source configuration",
173
+ benign: true,
174
+ });
175
+ }
176
+
156
177
  if (!context.git?.isRepo) {
157
178
  blocked.push({
158
179
  id: "repo-root",
@@ -257,16 +278,16 @@ export function buildPlanDecisions(context) {
257
278
  });
258
279
  }
259
280
  if (context.skills.summary.conflicts > 0) {
260
- const conflictMessage = `${context.skills.summary.conflicts} shared skills differ from the bundled baseline`;
281
+ const conflictMessage = `${context.skills.summary.conflicts} preserved user-modified global skills differ from the bundled baseline`;
261
282
  if (context.strictSharedSkills) {
262
283
  blocked.push({
263
284
  id: "shared-skills",
264
285
  kind: "skill-pack",
265
- reason: `${conflictMessage}; strict mode blocks instead of overwriting`,
286
+ reason: `${conflictMessage}; strict mode blocks instead of overwriting user-modified skills`,
266
287
  recovery: "inspect with pi-67 skills inventory, then sync or resolve manually",
267
288
  });
268
289
  } else {
269
- warnings.push(`${conflictMessage}; default update preserves existing different skills`);
290
+ warnings.push(`${conflictMessage}; default update preserves them`);
270
291
  }
271
292
  }
272
293
 
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ const args = process.argv.slice(2);
7
+
8
+ if (args.includes("--clean")) {
9
+ const { stripSettingsRuntimeMarkerText } = await import("../lib/settings-runtime-clean.mjs");
10
+ const input = fs.readFileSync(0, "utf8");
11
+ process.stdout.write(stripSettingsRuntimeMarkerText(input));
12
+ process.exit(0);
13
+ }
14
+
15
+ if (args.includes("--migrate")) {
16
+ const { migrateSettingsRuntimeState } = await import("../lib/settings-runtime-state.mjs");
17
+ const ctx = {
18
+ agentDir: path.resolve(valueAfter("--agent-dir") || process.cwd()),
19
+ repoRoot: path.resolve(valueAfter("--repo-root") || process.cwd()),
20
+ stateDir: path.resolve(valueAfter("--state-dir") || path.join(os.homedir(), ".pi", "pi67")),
21
+ };
22
+ const result = migrateSettingsRuntimeState(ctx, {
23
+ normalizeSettingsJson: args.includes("--normalize"),
24
+ installGitFilter: args.includes("--install-git-filter"),
25
+ dryRun: args.includes("--dry-run"),
26
+ });
27
+ if (args.includes("--json")) {
28
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
29
+ } else {
30
+ printHuman(result);
31
+ }
32
+ process.exit(result.errors.length > 0 ? 1 : 0);
33
+ }
34
+
35
+ process.stderr.write(`Usage:
36
+ settings-runtime-state-filter.mjs --clean
37
+ settings-runtime-state-filter.mjs --migrate --agent-dir DIR --repo-root DIR [--state-dir DIR] [--normalize] [--install-git-filter] [--json] [--dry-run]
38
+ `);
39
+ process.exit(2);
40
+
41
+ function valueAfter(name) {
42
+ const index = args.indexOf(name);
43
+ if (index === -1) return "";
44
+ return args[index + 1] || "";
45
+ }
46
+
47
+ function printHuman(result) {
48
+ if (result.markerFound) {
49
+ console.log(`PASS migrated settings.json lastChangelogVersion to ${result.statePath}`);
50
+ } else {
51
+ console.log("PASS settings.json runtime marker is already absent");
52
+ }
53
+ if (result.settingsNormalized) {
54
+ console.log("PASS normalized settings.json by removing runtime-only lastChangelogVersion");
55
+ }
56
+ if (result.gitFilterInstalled) {
57
+ console.log("PASS installed local git clean filter for settings.json runtime marker");
58
+ }
59
+ for (const item of result.skipped) {
60
+ console.log(`INFO ${item}`);
61
+ }
62
+ for (const item of result.errors) {
63
+ console.error(`FAIL ${item}`);
64
+ }
65
+ }