@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.
- package/CHANGELOG.md +44 -0
- package/README.md +17 -8
- package/package.json +1 -1
- package/scripts/check.mjs +96 -0
- package/src/cli.mjs +6 -3
- package/src/commands/backups.mjs +187 -1
- package/src/commands/doctor.mjs +30 -1
- package/src/commands/extensions.mjs +6 -1
- package/src/commands/external.mjs +28 -0
- package/src/commands/install.mjs +22 -0
- package/src/commands/manifest.mjs +21 -0
- package/src/commands/publish-check.mjs +24 -0
- package/src/commands/report.mjs +22 -0
- package/src/commands/self-update.mjs +19 -0
- package/src/commands/skills.mjs +83 -4
- package/src/commands/smoke.mjs +21 -0
- package/src/commands/status.mjs +20 -0
- package/src/commands/themes.mjs +29 -0
- package/src/commands/update.mjs +51 -2
- package/src/commands/version.mjs +22 -1
- package/src/commands/xtalpi.mjs +129 -2
- package/src/lib/args.mjs +7 -1
- package/src/lib/skill-policy.mjs +130 -3
- package/src/lib/update-plan.mjs +90 -6
- package/src/lib/update-safety.mjs +21 -1
package/src/commands/xtalpi.mjs
CHANGED
|
@@ -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
|
}
|
package/src/lib/skill-policy.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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);
|
package/src/lib/update-plan.mjs
CHANGED
|
@@ -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:
|
|
165
|
-
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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,
|