@bigking67/pi-67 0.10.5 → 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,29 @@
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
+
13
+ ## [0.10.6]
14
+
15
+ - Adds runtime request retry to the canonical `xtalpi-pi-tools` provider path,
16
+ including attempt/retry/suppression telemetry in debug JSONL output.
17
+ - Adds final-answer compliance repair to Bash and PowerShell xtalpi smoke:
18
+ validated tool runs that only miss required final text get one `--no-tools`
19
+ repair pass instead of rerunning tools.
20
+ - Accepts `pi-67 doctor --no-skill-list` and raises doctor skill-list timeout
21
+ defaults to 60 seconds.
22
+ - Supports command-level `pi-67 report --json`, emitting either a dry-run JSON
23
+ object or the generated report JSON.
24
+ - Runs full `scripts/pi67-smoke.sh --ci` in the npm publish workflow before
25
+ pack/publish.
26
+
3
27
  ## [0.10.5]
4
28
 
5
29
  - Uses `git fetch` plus `git merge --ff-only FETCH_HEAD` in Bash and
package/README.md CHANGED
@@ -52,6 +52,14 @@ For automation, `pi-67 update --check --json` includes explicit `actions`,
52
52
  `blocked`, and `warnings` arrays. Each action lists planned writes and preserved
53
53
  paths, so update previews stay auditable instead of relying on prose output.
54
54
 
55
+ Long or slow doctor runs can skip Pi's live skill listing while still checking
56
+ local metadata, config, provider/model, shared skill files, and endpoint
57
+ contracts:
58
+
59
+ ```bash
60
+ pi-67 doctor --no-skill-list
61
+ ```
62
+
55
63
  If the local manager may be stale, run the latest npm package for one repair:
56
64
 
57
65
  ```bash
@@ -121,8 +129,9 @@ The explicit theme setter also writes a runtime backup before changing
121
129
  `settings.json`; normal update never changes the selected theme.
122
130
 
123
131
  The manager writes lightweight state outside the repo at `~/.pi/pi67/state.json`.
124
- It records versions, paths, theme, provider/model, and commit information; it
125
- 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.
126
135
 
127
136
  ## Main commands
128
137
 
@@ -143,6 +152,7 @@ pi-67 doctor
143
152
  pi-67 smoke --quick
144
153
  pi-67 status
145
154
  pi-67 report
155
+ pi-67 report --json
146
156
  pi-67 version
147
157
  pi-67 xtalpi health
148
158
  pi-67 xtalpi smoke --quick
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bigking67/pi-67",
3
- "version": "0.10.5",
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
 
@@ -59,6 +65,14 @@ function runArgsSelfTests() {
59
65
  parseCommandOptions(["--help"], { bools: [] }).options.help,
60
66
  "command option parser must accept --help for every command",
61
67
  );
68
+ assert(
69
+ parseCommandOptions(["--no-skill-list"], { bools: ["no-skill-list"] }).options.noSkillList,
70
+ "command option parser must accept doctor --no-skill-list",
71
+ );
72
+ assert(
73
+ parseCommandOptions(["--json"], { bools: ["json"] }).options.json,
74
+ "command option parser must accept command-level --json",
75
+ );
62
76
  }
63
77
 
64
78
  function runCliHelpContractSelfTests() {
@@ -314,6 +328,22 @@ function runExtensionRegistrySelfTests() {
314
328
  );
315
329
  }
316
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
+
317
347
  function runUpdatePlanSelfTests() {
318
348
  assert(
319
349
  classifyGitShort(" M settings.json\n?? tmp.txt").preservedRuntime.includes("settings.json"),
@@ -388,8 +418,8 @@ function runUpdatePlanSelfTests() {
388
418
  skills: { summary: { missing: 0, conflicts: 2 } },
389
419
  });
390
420
  assert(
391
- buildPlanDecisions(sharedSkillConflict).warnings.some((item) => item.includes("preserves existing different skills")),
392
- "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",
393
423
  );
394
424
  const strictSharedSkillConflict = decisionsFixture({
395
425
  strictSharedSkills: true,
@@ -4,7 +4,7 @@ import { isWindows } from "../lib/platform.mjs";
4
4
 
5
5
  export async function doctorCommand(ctx, argv) {
6
6
  const { options } = parseCommandOptions(argv, {
7
- bools: ["json", "quiet", "dry-run", "deep-mcp", "strict-shared-skills"],
7
+ bools: ["json", "quiet", "dry-run", "deep-mcp", "no-skill-list", "strict-shared-skills"],
8
8
  strings: ["mcp-timeout-ms", "skill-list-timeout-seconds"],
9
9
  });
10
10
  if (options.help) {
@@ -19,6 +19,7 @@ export async function doctorCommand(ctx, argv) {
19
19
  if (options.strictSharedSkills) args.push(isWindows() ? "-StrictSharedSkills" : "--strict-shared-skills");
20
20
  if (!isWindows() && options.deepMcp) args.push("--deep-mcp");
21
21
  if (!isWindows() && options.mcpTimeoutMs) args.push("--mcp-timeout-ms", options.mcpTimeoutMs);
22
+ if (!isWindows() && options.noSkillList) args.push("--no-skill-list");
22
23
  if (options.skillListTimeoutSeconds) {
23
24
  args.push(isWindows() ? "-SkillListTimeoutSeconds" : "--skill-list-timeout-seconds", options.skillListTimeoutSeconds);
24
25
  }
@@ -38,9 +39,10 @@ Options:
38
39
  --quiet Reduce human output where supported.
39
40
  --deep-mcp Run deeper MCP probes on POSIX platforms.
40
41
  --mcp-timeout-ms N Timeout for deep MCP probes on POSIX platforms.
42
+ --no-skill-list Skip pi skill list on POSIX platforms; accepted as a no-op on Windows.
41
43
  --skill-list-timeout-seconds N
42
- Timeout for pi skill list on POSIX platforms.
43
- --strict-shared-skills Treat differing shared skills as blocking.
44
+ Timeout for pi skill list where enabled.
45
+ --strict-shared-skills Treat preserved user-modified shared skills as blocking.
44
46
  --dry-run Print the script invocation without running it.
45
47
 
46
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
 
@@ -1,10 +1,13 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
1
3
  import { parseCommandOptions } from "../lib/args.mjs";
2
4
  import { runDistroScript } from "../lib/distro-scripts.mjs";
3
5
  import { isWindows } from "../lib/platform.mjs";
6
+ import { printJson } from "../lib/output.mjs";
4
7
 
5
8
  export async function reportCommand(ctx, argv) {
6
9
  const { options } = parseCommandOptions(argv, {
7
- bools: ["dry-run", "no-doctor"],
10
+ bools: ["json", "dry-run", "no-doctor"],
8
11
  strings: ["operation", "output"],
9
12
  });
10
13
  if (options.help) {
@@ -15,21 +18,39 @@ export async function reportCommand(ctx, argv) {
15
18
  ? ["-AgentDir", ctx.agentDir, "-RepoRoot", ctx.repoRoot, "-SkillsDir", ctx.skillsDir]
16
19
  : ["--agent-dir", ctx.agentDir, "--repo-root", ctx.repoRoot, "--skills-dir", ctx.skillsDir];
17
20
  if (options.operation) args.push(isWindows() ? "-Operation" : "--operation", options.operation);
18
- if (options.output) args.push(isWindows() ? "-Output" : "--output", options.output);
21
+ const outputPath = options.output || path.join(ctx.agentDir, "pi67-report.json");
22
+ const resolvedOutputPath = path.isAbsolute(outputPath) ? outputPath : path.resolve(ctx.repoRoot, outputPath);
23
+ args.push(isWindows() ? "-Output" : "--output", outputPath);
19
24
  if (options.noDoctor) args.push(isWindows() ? "-NoDoctor" : "--no-doctor");
20
25
  if (ctx.dryRun || options.dryRun) args.push(isWindows() ? "-DryRun" : "--dry-run");
26
+ const json = ctx.json || options.json;
27
+ if (json && (ctx.dryRun || options.dryRun)) {
28
+ printJson({
29
+ schema: "pi67.report-command.v1",
30
+ dryRun: true,
31
+ output: resolvedOutputPath,
32
+ operation: options.operation || "manual",
33
+ doctor: options.noDoctor ? "skipped" : "enabled",
34
+ });
35
+ return;
36
+ }
21
37
  runDistroScript(ctx, { sh: "pi67-report.sh", ps1: "pi67-report.ps1" }, args, {
22
38
  dryRun: false,
39
+ stdio: json ? "pipe" : "inherit",
23
40
  });
41
+ if (json) {
42
+ process.stdout.write(fs.readFileSync(resolvedOutputPath, "utf8"));
43
+ }
24
44
  }
25
45
 
26
46
  function printReportHelp() {
27
47
  process.stdout.write(`pi-67 report - generate pi67-report.json
28
48
 
29
49
  Usage:
30
- pi-67 report [--operation NAME] [--output FILE] [--no-doctor] [--dry-run]
50
+ pi-67 report [--json] [--operation NAME] [--output FILE] [--no-doctor] [--dry-run]
31
51
 
32
52
  Options:
53
+ --json Emit the generated report JSON to stdout.
33
54
  --operation NAME Operation label to embed in the report.
34
55
  --output FILE Output path. Defaults to the distro report location.
35
56
  --no-doctor Skip doctor data collection where supported.
@@ -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.
@@ -17,11 +17,15 @@ export function runDistroScript(ctx, names, args = [], options = {}) {
17
17
  cwd: ctx.repoRoot,
18
18
  dryRun: options.dryRun,
19
19
  env: options.env,
20
+ stdio: options.stdio,
21
+ timeoutMs: options.timeoutMs,
20
22
  });
21
23
  }
22
24
  return runCommand("bash", [file, ...args], {
23
25
  cwd: ctx.repoRoot,
24
26
  dryRun: options.dryRun,
25
27
  env: options.env,
28
+ stdio: options.stdio,
29
+ timeoutMs: options.timeoutMs,
26
30
  });
27
31
  }
@@ -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
+ }