@comento/pota-harness 0.0.0-stage → 1.0.0

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.
Files changed (99) hide show
  1. package/.agents/workflows/pota-doctor.md +16 -0
  2. package/.agents/workflows/pota-install.md +15 -0
  3. package/.agents/workflows/pota-memory.md +17 -0
  4. package/.agents/workflows/pota-plan.md +22 -0
  5. package/.agents/workflows/pota-review.md +16 -0
  6. package/.agents/workflows/pota-toolcheck.md +9 -0
  7. package/.agents/workflows/pota-uninstall.md +9 -0
  8. package/.agents/workflows/pota-upgrade.md +15 -0
  9. package/.agents/workflows/pota-work.md +24 -0
  10. package/.claude/agents/explorer.md +7 -0
  11. package/.claude/agents/worker.md +7 -0
  12. package/.claude/commands/pota-doctor.md +5 -0
  13. package/.claude/commands/pota-install.md +9 -0
  14. package/.claude/commands/pota-memory.md +5 -0
  15. package/.claude/commands/pota-plan.md +5 -0
  16. package/.claude/commands/pota-review.md +5 -0
  17. package/.claude/commands/pota-toolcheck.md +7 -0
  18. package/.claude/commands/pota-uninstall.md +5 -0
  19. package/.claude/commands/pota-upgrade.md +9 -0
  20. package/.claude/commands/pota-work.md +7 -0
  21. package/.codex-plugin/plugin.json +33 -0
  22. package/LICENSE +21 -0
  23. package/README.de.md +166 -0
  24. package/README.es.md +166 -0
  25. package/README.fr.md +166 -0
  26. package/README.ja.md +166 -0
  27. package/README.ko.md +184 -0
  28. package/README.md +191 -2
  29. package/README.pt.md +166 -0
  30. package/README.zh.md +166 -0
  31. package/agents/explorer.md +36 -0
  32. package/agents/worker.md +45 -0
  33. package/commands/pota-doctor.md +28 -0
  34. package/commands/pota-install.md +29 -0
  35. package/commands/pota-memory.md +17 -0
  36. package/commands/pota-plan.md +22 -0
  37. package/commands/pota-review.md +16 -0
  38. package/commands/pota-toolcheck.md +17 -0
  39. package/commands/pota-uninstall.md +27 -0
  40. package/commands/pota-upgrade.md +26 -0
  41. package/commands/pota-work.md +24 -0
  42. package/docs/model-guidance/README.md +67 -0
  43. package/docs/model-guidance/claude.md +49 -0
  44. package/docs/model-guidance/codex.md +68 -0
  45. package/hooks/hooks.json +65 -0
  46. package/package.json +47 -4
  47. package/scripts/pota +14 -0
  48. package/scripts/pota-auto-upgrade.mjs +342 -0
  49. package/scripts/pota-cleanup.mjs +194 -0
  50. package/scripts/pota-goal-cli.mjs +337 -0
  51. package/scripts/pota-goal-evidence.mjs +186 -0
  52. package/scripts/pota-goal-history.mjs +316 -0
  53. package/scripts/pota-goal-operations-evidence.mjs +213 -0
  54. package/scripts/pota-goal-operations-mutations.mjs +97 -0
  55. package/scripts/pota-goal-operations.mjs +281 -0
  56. package/scripts/pota-goal-policy.mjs +62 -0
  57. package/scripts/pota-goal-state-validation.mjs +361 -0
  58. package/scripts/pota-goal-state.mjs +136 -0
  59. package/scripts/pota-goal.mjs +69 -0
  60. package/scripts/pota-index.mjs +1052 -0
  61. package/scripts/pota-lifecycle-claude.mjs +173 -0
  62. package/scripts/pota-lifecycle-commands.mjs +418 -0
  63. package/scripts/pota-lifecycle-config.mjs +153 -0
  64. package/scripts/pota-lifecycle-hosts.mjs +98 -0
  65. package/scripts/pota-lifecycle-integrity.mjs +272 -0
  66. package/scripts/pota-lifecycle-layout.mjs +52 -0
  67. package/scripts/pota-lifecycle-lock.mjs +205 -0
  68. package/scripts/pota-lifecycle-mirror.mjs +83 -0
  69. package/scripts/pota-lifecycle-staging.mjs +156 -0
  70. package/scripts/pota-lifecycle-transaction-journal.mjs +244 -0
  71. package/scripts/pota-lifecycle-transaction-targets.mjs +119 -0
  72. package/scripts/pota-lifecycle-transaction.mjs +12 -0
  73. package/scripts/pota-lifecycle.mjs +244 -0
  74. package/scripts/pota-paths.mjs +190 -0
  75. package/scripts/pota-safe-fs.mjs +277 -0
  76. package/scripts/pota-state-context.mjs +281 -0
  77. package/scripts/pota-state-hook.mjs +26 -0
  78. package/scripts/pota-state-init.mjs +80 -0
  79. package/scripts/pota-state.mjs +245 -0
  80. package/scripts/pota-toolcheck-detect.mjs +229 -0
  81. package/scripts/pota-toolcheck-runtime.mjs +178 -0
  82. package/scripts/pota-toolcheck.mjs +221 -0
  83. package/scripts/pota-update-state.mjs +219 -0
  84. package/scripts/pota.mjs +200 -0
  85. package/skills/pota-memory/SKILL.md +52 -0
  86. package/skills/pota-plan/SKILL.md +66 -0
  87. package/skills/pota-plan/delegation.md +65 -0
  88. package/skills/pota-plan/heavy-path.md +28 -0
  89. package/skills/pota-plan/planning-rules.md +78 -0
  90. package/skills/pota-review/SKILL.md +56 -0
  91. package/skills/pota-work/SKILL.md +71 -0
  92. package/skills/pota-work/delegation.md +65 -0
  93. package/skills/pota-work/heavy-path.md +18 -0
  94. package/skills/pota-work/recovery.md +29 -0
  95. package/skills/pota-work/verification.md +79 -0
  96. package/templates/MEMORY.md +12 -0
  97. package/templates/PLAN.md +9 -0
  98. package/templates/domain-memory.md +22 -0
  99. package/templates/work-plan.md +177 -0
@@ -0,0 +1,342 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
3
+ import { realpathSync } from "node:fs";
4
+ import { lstat, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
5
+ import { join, relative } from "node:path";
6
+ import { fileURLToPath, pathToFileURL } from "node:url";
7
+ import { createLifecycleLock } from "./pota-lifecycle-lock.mjs";
8
+ import { assertNoSymlinkComponents } from "./pota-safe-fs.mjs";
9
+ import {
10
+ AUTO_UPGRADE_EXIT, UPDATE_PACKAGE_NAME, autoUpgradeSetting, emptyUpdateState, noticeRecord, readInstallManifest,
11
+ readJsonFile, readPendingNotice, readUpdateState, removeUpdateState, updatePaths, writeFileAtomic, writeJsonAtomic,
12
+ } from "./pota-update-state.mjs";
13
+
14
+ // The detached auto-upgrade runner (`run`, started by the SessionStart hook) and the `pota auto-upgrade` CLI.
15
+ // The runner is the old version and the package it installs is the new one. Only three things are fixed between
16
+ // versions: the `pota-lifecycle.mjs upgrade --auto` call, `version` in POTA_HOME/install/lifecycle.json, and the
17
+ // meaning of the lifecycle exit code (0 done, AUTO_UPGRADE_EXIT.manual a Claude Code step left to the user, anything
18
+ // else not confirmed).
19
+ export const AUTO_UPGRADE_USAGE = "Usage: pota auto-upgrade status|on|off|run";
20
+ const VIEW_TIMEOUT_MS = 30_000;
21
+ const INSTALL_TIMEOUT_MS = 120_000;
22
+ const LIFECYCLE_TIMEOUT_MS = 300_000;
23
+ const LOG_LIMIT_BYTES = 64 * 1024;
24
+ const HOUR_MS = 60 * 60 * 1000;
25
+ const DAY_MS = 24 * HOUR_MS;
26
+ const REGISTRY_VERSION = /^\d+\.\d+\.\d+$/;
27
+ const REGISTRY_VERSION_MAX_LENGTH = 32;
28
+ const LOCK_BUSY = "Timed out waiting for Pota lifecycle lock";
29
+
30
+ class UsageError extends Error {}
31
+
32
+ if (isCliEntry()) {
33
+ try {
34
+ process.exitCode = await main(process.argv.slice(2));
35
+ } catch (error) {
36
+ console.error(`Pota auto-upgrade error: ${error instanceof Error ? error.message : String(error)}`);
37
+ process.exitCode = error instanceof UsageError ? 2 : 1;
38
+ }
39
+ }
40
+
41
+ // Exit codes follow the root CLI: 0 success or help, 1 an execution error or a failed run, 2 a usage error.
42
+ export async function main(args, env = process.env) {
43
+ const [command, ...rest] = args;
44
+ if (command === undefined || command === "help" || command === "--help") {
45
+ console.log(AUTO_UPGRADE_USAGE);
46
+ return 0;
47
+ }
48
+ if (!["status", "on", "off", "run"].includes(command)) throw new UsageError(`unknown command: ${command}`);
49
+ if (rest.length > 0) throw new UsageError(`auto-upgrade ${command} does not accept arguments: ${rest.join(" ")}`);
50
+ if (command === "on" || command === "off") setAutoUpgrade(env, command === "on");
51
+ if (command !== "run") {
52
+ printJson(statusReport(env));
53
+ return 0;
54
+ }
55
+ const report = await runAutoUpgrade(env);
56
+ printJson(report);
57
+ return ["failed", "check-failed", "manual"].includes(report.result?.kind) ? 1 : 0;
58
+ }
59
+
60
+ export function statusReport(env = process.env) {
61
+ const paths = updatePaths(env);
62
+ const setting = autoUpgradeSetting(env, paths);
63
+ const manifest = readInstallManifest(paths);
64
+ const state = readUpdateState(paths);
65
+ return {
66
+ enabled: setting.enabled,
67
+ source: setting.source,
68
+ installed: manifest,
69
+ lastCheckedAt: state?.lastCheckedAt ?? null,
70
+ latestSeen: state?.latestSeen ?? null,
71
+ nextAttemptAt: state?.nextAttemptAt ?? null,
72
+ consecutiveFailures: state?.consecutiveFailures ?? 0,
73
+ failedVersion: state?.failedVersion ?? null,
74
+ lastResult: state?.lastResult ?? null,
75
+ pendingNotice: readPendingNotice(paths),
76
+ config: paths.config,
77
+ statePath: paths.state,
78
+ log: paths.log,
79
+ };
80
+ }
81
+
82
+ // Other config.json keys are kept. A config.json that is not a readable JSON object is left as it is.
83
+ function setAutoUpgrade(env, enabled) {
84
+ const paths = updatePaths(env);
85
+ const read = readJsonFile(paths.config);
86
+ const object = read.status === "ok" && read.value && typeof read.value === "object" && !Array.isArray(read.value);
87
+ if (read.status !== "missing" && !object) throw new Error(`${paths.config} is not a readable JSON object; fix or remove it`);
88
+ writeJsonAtomic(paths.config, { ...(object ? read.value : {}), autoUpgrade: enabled });
89
+ }
90
+
91
+ export async function runAutoUpgrade(env = process.env) {
92
+ const paths = updatePaths(env);
93
+ if (!autoUpgradeSetting(env, paths).enabled) return { ran: false, reason: "disabled" };
94
+ if (!readInstallManifest(paths)) return { ran: false, reason: "not-installed" };
95
+ await assertNoSymlinkComponents(paths.home, paths.stage);
96
+ await mkdir(paths.root, { recursive: true });
97
+ // A held update.lock means another runner is working: exit without waiting. A lock left by a dead process is
98
+ // reclaimed by the lifecycle lock rules.
99
+ let report;
100
+ try {
101
+ report = await createLifecycleLock({ lifecycleLockPath: paths.lock })
102
+ .withLifecycleLock(() => runLocked(env, paths), { timeoutMs: 1, retryMs: 1 });
103
+ } catch (error) {
104
+ if (String(error?.message).startsWith(LOCK_BUSY)) return { ran: false, reason: "locked" };
105
+ throw error;
106
+ }
107
+ // An uninstall finished while this runner worked: its update state goes too, as uninstall would have removed it.
108
+ if (report.reason === "not-installed" && readJsonFile(paths.manifest).status === "missing") await removeUpdateState(env);
109
+ return report;
110
+ }
111
+
112
+ async function runLocked(env, paths) {
113
+ // Read again under the lock: a lifecycle command or another runner may have changed the installation.
114
+ if (!autoUpgradeSetting(env, paths).enabled) return { ran: false, reason: "disabled" };
115
+ const manifest = readInstallManifest(paths);
116
+ if (!manifest) return { ran: false, reason: "not-installed" };
117
+ const now = new Date();
118
+ const previous = { ...emptyUpdateState(), ...(readUpdateState(paths) ?? {}) };
119
+ if (manifest.origin === "checkout") {
120
+ const state = { ...previous, lastCheckedAt: now.toISOString(), lastResult: result("skipped", now, manifest.version, null, "checkout-origin installation") };
121
+ writeJsonAtomic(paths.state, state);
122
+ return { ran: false, reason: "checkout", result: state.lastResult };
123
+ }
124
+ const log = [];
125
+ let state;
126
+ try {
127
+ const lookup = runNpm(["view", `${UPDATE_PACKAGE_NAME}@latest`, "version", "--json"], { cwd: paths.root, env, timeout: VIEW_TIMEOUT_MS });
128
+ log.push(logEntry("npm view", lookup));
129
+ const latest = registryVersion(lookup);
130
+ state = latest.error
131
+ ? checkFailure(previous, { now, from: manifest.version, to: null, detail: latest.error })
132
+ : isNewer(latest.version, manifest.version)
133
+ ? await upgradeTo(latest.version, { env, paths, manifest, previous, now, log })
134
+ : settled(previous, { now, kind: "current", from: manifest.version, to: latest.version });
135
+ } catch (error) {
136
+ // An unexpected error (a file system failure around the stage) is recorded like a lookup failure: no notice, and
137
+ // a backoff so the next sessions do not retry it at once.
138
+ const detail = `unexpected error: ${error instanceof Error ? error.message : String(error)}`;
139
+ log.push(detail);
140
+ state = checkFailure(previous, { now, from: manifest.version, to: null, detail });
141
+ }
142
+ if (state.uninstalled) return { ran: false, reason: "not-installed" };
143
+ try {
144
+ writeJsonAtomic(paths.state, state);
145
+ return { ran: true, result: state.lastResult };
146
+ } finally {
147
+ writeLog(paths.log, now, log);
148
+ }
149
+ }
150
+
151
+ async function upgradeTo(target, { env, paths, manifest, previous, now, log }) {
152
+ const from = manifest.version;
153
+ await rm(paths.stage, { recursive: true, force: true });
154
+ await assertNoSymlinkComponents(paths.home, paths.stage);
155
+ await mkdir(paths.stage, { recursive: true });
156
+ // npm installs into the nearest package.json; without this one it would climb to POTA_HOME or the home directory.
157
+ await writeFile(join(paths.stage, "package.json"), `${JSON.stringify({ name: "pota-auto-upgrade-stage", private: true }, null, 2)}\n`);
158
+ try {
159
+ // The stage is the cwd, so no path is passed to npm (npm.cmd splits paths with spaces on Windows).
160
+ const install = runNpm(["install", "--ignore-scripts", "--no-audit", "--no-fund", "--no-save", `${UPDATE_PACKAGE_NAME}@${target}`], {
161
+ cwd: paths.stage, env, timeout: INSTALL_TIMEOUT_MS,
162
+ });
163
+ log.push(logEntry("npm install", install));
164
+ if (install.error || install.status !== 0) {
165
+ return checkFailure(previous, { now, from, to: target, detail: `npm install failed: ${lastLine(install)}` });
166
+ }
167
+ const problem = await verifyStagedPackage(paths.stage, target);
168
+ if (problem) return upgradeFailure(previous, { paths, now, from, to: target, detail: `verification failed: ${problem}` });
169
+ const lifecycle = spawnSync(process.execPath, [join(paths.stage, "node_modules", ...UPDATE_PACKAGE_NAME.split("/"), "scripts", "pota-lifecycle.mjs"), "upgrade", "--auto"], {
170
+ cwd: paths.root, env, encoding: "utf8", timeout: LIFECYCLE_TIMEOUT_MS, windowsHide: true, maxBuffer: 16 * 1024 * 1024,
171
+ });
172
+ log.push(logEntry("pota-lifecycle upgrade --auto", lifecycle));
173
+ // An uninstall finished while the package was staged (`upgrade --auto` then installs nothing): there is nothing
174
+ // left to upgrade or to report.
175
+ if (readJsonFile(paths.manifest).status === "missing") return { uninstalled: true };
176
+ const installed = readInstallManifest(paths);
177
+ // The manifest decides: the files moved to the target version or they did not.
178
+ if (installed?.version !== target) {
179
+ return upgradeFailure(previous, { paths, now, from, to: target, detail: `upgrade exited ${lifecycle.status ?? lifecycle.error?.code}: ${lastLine(lifecycle)}` });
180
+ }
181
+ if (lifecycle.status === 0 || lifecycle.status === AUTO_UPGRADE_EXIT.manual) {
182
+ const kind = lifecycle.status === 0 ? "updated" : "manual";
183
+ writeJsonAtomic(paths.notice, noticeRecord(kind, from, target, now.toISOString()));
184
+ return settled(previous, { now, kind, from, to: target, detail: kind === "manual" ? lastLine(lifecycle) : null });
185
+ }
186
+ // The files moved but the run failed after its commit: reported once like a failed upgrade. The next check finds
187
+ // the target version installed, so the upgrade is not retried.
188
+ return upgradeFailure(previous, { paths, now, from, to: target, detail: `upgrade exited ${lifecycle.status ?? lifecycle.error?.code} after its commit: ${lastLine(lifecycle)}` });
189
+ } finally {
190
+ // A stage that cannot be removed (a held file, a changed permission) must not hide the result; the next run
191
+ // removes it before staging again.
192
+ try { await rm(paths.stage, { recursive: true, force: true }); } catch (error) { log.push(`stage removal failed: ${error.message}`); }
193
+ }
194
+ }
195
+
196
+ function settled(previous, { now, kind, from, to, detail = null }) {
197
+ return {
198
+ ...previous, lastCheckedAt: now.toISOString(), latestSeen: to, consecutiveFailures: 0, nextAttemptAt: null,
199
+ failedVersion: null, lastResult: result(kind, now, from, to, detail),
200
+ };
201
+ }
202
+
203
+ // Lookup and install failures (offline, no npm, a registry error) back off 1h, 2h, … up to 24h without a notice.
204
+ function checkFailure(previous, { now, from, to, detail }) {
205
+ const failures = previous.lastResult?.kind === "check-failed" ? failureCount(previous) + 1 : 1;
206
+ return {
207
+ ...previous, latestSeen: to ?? previous.latestSeen, consecutiveFailures: failures,
208
+ nextAttemptAt: new Date(now.getTime() + Math.min(HOUR_MS * 2 ** (failures - 1), DAY_MS)).toISOString(),
209
+ lastResult: result("check-failed", now, from, to, detail),
210
+ };
211
+ }
212
+
213
+ // A version that failed verification or the upgrade backs off 24h, 48h, … up to 7 days, with one notice per version.
214
+ // A newer release starts over.
215
+ function upgradeFailure(previous, { paths, now, from, to, detail }) {
216
+ const sameVersion = previous.failedVersion === to;
217
+ const failures = sameVersion && previous.lastResult?.kind === "failed" ? failureCount(previous) + 1 : 1;
218
+ if (!sameVersion) writeJsonAtomic(paths.notice, noticeRecord("failed", from, to, now.toISOString()));
219
+ return {
220
+ ...previous, lastCheckedAt: now.toISOString(), latestSeen: to, consecutiveFailures: failures, failedVersion: to,
221
+ nextAttemptAt: new Date(now.getTime() + Math.min(DAY_MS * 2 ** (failures - 1), 7 * DAY_MS)).toISOString(),
222
+ lastResult: result("failed", now, from, to, detail),
223
+ };
224
+ }
225
+
226
+ function failureCount(state) {
227
+ return Number.isSafeInteger(state.consecutiveFailures) && state.consecutiveFailures > 0 ? state.consecutiveFailures : 0;
228
+ }
229
+
230
+ function result(kind, now, from, to, detail) {
231
+ return { kind, at: now.toISOString(), from, to, detail };
232
+ }
233
+
234
+ // Only an exact x.y.z release of at most 32 characters is accepted; npm 12 may wrap it in an object.
235
+ function registryVersion(lookup) {
236
+ if (lookup.error || lookup.status !== 0) return { error: `npm view failed: ${lastLine(lookup)}` };
237
+ let value;
238
+ try { value = JSON.parse(lookup.stdout); } catch { return { error: "npm view did not return JSON" }; }
239
+ if (value && typeof value === "object" && !Array.isArray(value)) value = value.version;
240
+ if (typeof value !== "string" || value.length > REGISTRY_VERSION_MAX_LENGTH || !REGISTRY_VERSION.test(value)) {
241
+ return { error: `npm view returned an invalid version: ${String(JSON.stringify(value)).slice(0, 80)}` };
242
+ }
243
+ return { version: value };
244
+ }
245
+
246
+ // Strictly newer by major.minor.patch, or the same core release of an installed prerelease. Never a downgrade.
247
+ function isNewer(latest, installed) {
248
+ const match = /^(\d+)\.(\d+)\.(\d+)(-.+)?$/.exec(installed);
249
+ if (!match) return false;
250
+ const next = latest.split(".").map(BigInt);
251
+ const current = match.slice(1, 4).map(BigInt);
252
+ for (let index = 0; index < 3; index += 1) if (next[index] !== current[index]) return next[index] > current[index];
253
+ return Boolean(match[4]);
254
+ }
255
+
256
+ // The staged package must be exactly @comento/pota-harness@<target> with no dependencies, symlinks, or special files.
257
+ async function verifyStagedPackage(stage, target) {
258
+ const modules = join(stage, "node_modules");
259
+ // A scoped name occupies two levels (node_modules/@scope/name); each level may hold only its own segment.
260
+ const segments = UPDATE_PACKAGE_NAME.split("/");
261
+ const packageRoot = join(modules, ...segments);
262
+ try {
263
+ let level = modules;
264
+ for (const segment of segments) {
265
+ if (!(await lstat(level)).isDirectory()) return `not a directory: ${relative(stage, level)}`;
266
+ for (const entry of (await readdir(level)).sort()) {
267
+ const hidden = level === modules && entry.startsWith(".");
268
+ if (!hidden && entry !== segment) return `unexpected package in node_modules: ${relative(modules, join(level, entry))}`;
269
+ }
270
+ level = join(level, segment);
271
+ }
272
+ const special = await firstSpecialEntry(packageRoot);
273
+ if (special) return `not a regular file or directory: ${relative(stage, special)}`;
274
+ const manifest = JSON.parse(await readFile(join(packageRoot, "package.json"), "utf8"));
275
+ if (manifest?.name !== UPDATE_PACKAGE_NAME || manifest.version !== target) {
276
+ return `package.json names ${manifest?.name}@${manifest?.version}, expected ${UPDATE_PACKAGE_NAME}@${target}`;
277
+ }
278
+ const plugin = JSON.parse(await readFile(join(packageRoot, ".codex-plugin", "plugin.json"), "utf8"));
279
+ if (plugin?.name !== "pota" || plugin.version !== target) {
280
+ return `.codex-plugin/plugin.json names ${plugin?.name}@${plugin?.version}, expected pota@${target}`;
281
+ }
282
+ if (!(await lstat(join(packageRoot, "scripts", "pota-lifecycle.mjs"))).isFile()) return "scripts/pota-lifecycle.mjs is not a regular file";
283
+ return null;
284
+ } catch (error) {
285
+ return error instanceof Error ? error.message : String(error);
286
+ }
287
+ }
288
+
289
+ async function firstSpecialEntry(path) {
290
+ const info = await lstat(path);
291
+ if (info.isFile()) return null;
292
+ if (!info.isDirectory()) return path;
293
+ for (const entry of (await readdir(path)).sort()) {
294
+ const found = await firstSpecialEntry(join(path, entry));
295
+ if (found) return found;
296
+ }
297
+ return null;
298
+ }
299
+
300
+ // POTA_NPM_CLI follows the POTA_CLAUDE_CLI rule: a .mjs/.js stand-in runs through this Node binary, anything else
301
+ // is run directly (through the shell on Windows for npm.cmd).
302
+ function runNpm(args, { cwd, env, timeout }) {
303
+ const cli = env.POTA_NPM_CLI ?? "npm";
304
+ const options = { cwd, env, encoding: "utf8", timeout, windowsHide: true, maxBuffer: 16 * 1024 * 1024 };
305
+ if (/\.m?js$/.test(cli)) return spawnSync(process.execPath, [cli, ...args], options);
306
+ return spawnSync(cli, args, { ...options, shell: process.platform === "win32" });
307
+ }
308
+
309
+ function logEntry(label, run) {
310
+ return `$ ${label} (exit ${run.status ?? run.error?.code ?? run.signal ?? "unknown"})\n${run.stdout ?? ""}${run.stderr ?? ""}`;
311
+ }
312
+
313
+ // The last run's npm and lifecycle output, keeping the end when it exceeds 64 KiB.
314
+ function writeLog(path, now, entries) {
315
+ try {
316
+ let text = `${now.toISOString()}\n${entries.join("\n")}\n`;
317
+ const bytes = Buffer.from(text, "utf8");
318
+ if (bytes.length > LOG_LIMIT_BYTES) text = bytes.subarray(bytes.length - LOG_LIMIT_BYTES).toString("utf8");
319
+ while (Buffer.byteLength(text, "utf8") > LOG_LIMIT_BYTES) text = text.slice(1);
320
+ writeFileAtomic(path, text);
321
+ } catch {
322
+ // The log is diagnostic only.
323
+ }
324
+ }
325
+
326
+ function lastLine(run) {
327
+ const text = [run.stderr, run.stdout].map((value) => String(value ?? "").trim()).find(Boolean) ?? run.error?.message ?? `exit ${run.status}`;
328
+ return text.split("\n").filter((line) => line.trim()).at(-1)?.trim().slice(0, 300) ?? "";
329
+ }
330
+
331
+ function printJson(value) {
332
+ console.log(JSON.stringify(value, null, 2));
333
+ }
334
+
335
+ function isCliEntry() {
336
+ if (!process.argv[1]) return false;
337
+ try {
338
+ return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
339
+ } catch {
340
+ return import.meta.url === pathToFileURL(process.argv[1]).href;
341
+ }
342
+ }
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env node
2
+ import { constants, readFileSync } from "node:fs";
3
+ import { lstat, open, readdir, rm, rmdir } from "node:fs/promises";
4
+ import { hostname } from "node:os";
5
+ import { join, resolve } from "node:path";
6
+ import { pathToFileURL } from "node:url";
7
+ import { reconcileWorktreeState } from "./pota-goal.mjs";
8
+ import { resolvePluginRoot, resolveRepoRoot } from "./pota-paths.mjs";
9
+ import { lockOwnerIsDead, parseLockRecord } from "./pota-safe-fs.mjs";
10
+
11
+ const staleLockPrefix = "state.lock.stale.";
12
+ const staleLockName = /^state\.lock\.stale\.(.+)\.[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
13
+ const MAX_LOCK_RECORD_BYTES = 4096;
14
+ const usage = "Usage: pota cleanup [--root PATH] [--check | --yes]";
15
+
16
+ export async function cleanupProjectPota({ root = process.cwd(), apply = false } = {}) {
17
+ const repoRoot = resolve(root);
18
+ const legacyRoot = join(repoRoot, ".pota");
19
+ let rootStat;
20
+ try {
21
+ rootStat = await lstat(legacyRoot);
22
+ } catch (error) {
23
+ if (error?.code === "ENOENT") {
24
+ return { root: repoRoot, apply, known: [], unknown: [], removed: [], projectDirectoryRemoved: false, preservedReasons: {} };
25
+ }
26
+ throw error;
27
+ }
28
+ if (rootStat.isSymbolicLink()) throw new Error(`Legacy Pota root must not be a symbolic link: ${legacyRoot}`);
29
+ if (!rootStat.isDirectory()) throw new Error(`Legacy Pota root is not a directory: ${legacyRoot}`);
30
+
31
+ const entries = await readdir(legacyRoot, { withFileTypes: true });
32
+ const known = [];
33
+ const unknown = [];
34
+ const preservedReasons = {};
35
+ for (const entry of entries) {
36
+ if (entry.isFile() && isLockFileName(entry.name)) {
37
+ // Lock files are Pota-owned only when their content is a lock record that may be removed.
38
+ const reason = await lockFileReason(join(legacyRoot, entry.name), entry.name);
39
+ if (reason === null) known.push(entry.name);
40
+ else {
41
+ unknown.push(entry.name);
42
+ preservedReasons[entry.name] = reason;
43
+ }
44
+ continue;
45
+ }
46
+ const isKnownFile = entry.isFile() && entry.name === "state.json";
47
+ const isKnownDirectory = entry.isDirectory() && entry.name === "tools";
48
+ (isKnownFile || isKnownDirectory ? known : unknown).push(entry.name);
49
+ }
50
+ if (!apply) return cleanupResult({ repoRoot, apply, known, unknown, removed: [], projectDirectoryRemoved: false, preservedReasons });
51
+
52
+ if (known.includes("state.json")) await reconcileWorktreeState({ root: repoRoot });
53
+ const removed = [];
54
+ for (const name of [...known]) {
55
+ const path = join(legacyRoot, name);
56
+ try {
57
+ const stat = await lstat(path);
58
+ const expectedType = name === "tools" ? stat.isDirectory() : stat.isFile();
59
+ if (!expectedType) continue;
60
+ if (isLockFileName(name)) {
61
+ // Judge the lock again right before removal; a lock that changed owner is preserved.
62
+ const reason = await lockFileReason(path, name);
63
+ if (reason !== null) {
64
+ known.splice(known.indexOf(name), 1);
65
+ unknown.push(name);
66
+ preservedReasons[name] = reason;
67
+ continue;
68
+ }
69
+ }
70
+ await rm(path, { recursive: name === "tools", force: true });
71
+ removed.push(name);
72
+ } catch (error) {
73
+ if (error?.code !== "ENOENT") throw error;
74
+ }
75
+ }
76
+ let projectDirectoryRemoved = false;
77
+ try {
78
+ await rmdir(legacyRoot);
79
+ projectDirectoryRemoved = true;
80
+ } catch (error) {
81
+ if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error?.code)) throw error;
82
+ }
83
+ return cleanupResult({ repoRoot, apply, known, unknown, removed, projectDirectoryRemoved, preservedReasons });
84
+ }
85
+
86
+ function cleanupResult({ repoRoot, apply, known, unknown, removed, projectDirectoryRemoved, preservedReasons }) {
87
+ return {
88
+ root: repoRoot,
89
+ apply,
90
+ known: known.sort(),
91
+ unknown: unknown.sort(),
92
+ removed: removed.sort(),
93
+ projectDirectoryRemoved,
94
+ preservedReasons: Object.fromEntries(Object.keys(preservedReasons).sort().map((name) => [name, preservedReasons[name]])),
95
+ };
96
+ }
97
+
98
+ function isLockFileName(name) {
99
+ return name === "state.lock" || name.startsWith(staleLockPrefix);
100
+ }
101
+
102
+ // Returns null when the lock file may be removed, otherwise the reason it is preserved.
103
+ async function lockFileReason(path, name) {
104
+ let nonce = null;
105
+ if (name !== "state.lock") {
106
+ const match = staleLockName.exec(name);
107
+ if (!match) return "invalid-lock-record";
108
+ nonce = match[1];
109
+ }
110
+ const record = await readLockRecord(path);
111
+ if (!record) return "invalid-lock-record";
112
+ if (nonce !== null) return record.nonce === nonce ? null : "invalid-lock-record";
113
+ if (lockOwnerIsDead(record)) return null;
114
+ return record.hostname === hostname() ? "live-lock-owner" : "foreign-host-lock";
115
+ }
116
+
117
+ async function readLockRecord(path) {
118
+ let handle;
119
+ try {
120
+ // Never follow a link or block on a special file that replaced the inspected entry.
121
+ handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
122
+ const stat = await handle.stat();
123
+ if (!stat.isFile() || stat.size > MAX_LOCK_RECORD_BYTES) return null;
124
+ const buffer = Buffer.alloc(MAX_LOCK_RECORD_BYTES + 1);
125
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
126
+ if (bytesRead > MAX_LOCK_RECORD_BYTES) return null;
127
+ return parseLockRecord(buffer.subarray(0, bytesRead).toString("utf8"));
128
+ } catch {
129
+ return null;
130
+ } finally {
131
+ await handle?.close();
132
+ }
133
+ }
134
+
135
+ class CleanupUsageError extends Error {}
136
+
137
+ function parseCleanupArgs(args) {
138
+ const provided = new Set();
139
+ let explicitRoot = null;
140
+ for (let index = 0; index < args.length; index += 1) {
141
+ const argument = args[index];
142
+ if (["--help", "--version", "--check", "--yes"].includes(argument)) {
143
+ if (provided.has(argument)) throw new CleanupUsageError(`${argument} may only be provided once`);
144
+ provided.add(argument);
145
+ } else if (argument === "--root" || argument.startsWith("--root=")) {
146
+ if (explicitRoot !== null) throw new CleanupUsageError("--root may only be provided once");
147
+ const value = argument === "--root" ? args[index + 1] : argument.slice("--root=".length);
148
+ if (!value || value.startsWith("--")) throw new CleanupUsageError("--root requires a path");
149
+ explicitRoot = value;
150
+ if (argument === "--root") index += 1;
151
+ } else {
152
+ throw new CleanupUsageError(`unknown option: ${argument}`);
153
+ }
154
+ }
155
+ if (provided.has("--help") && provided.has("--version")) throw new CleanupUsageError("--help and --version cannot be used together");
156
+ if (provided.has("--check") && provided.has("--yes")) throw new CleanupUsageError("--check and --yes cannot be used together");
157
+ const action = provided.has("--help") ? "help" : provided.has("--version") ? "version" : "cleanup";
158
+ return { action, explicitRoot, apply: provided.has("--yes") };
159
+ }
160
+
161
+ async function main(args) {
162
+ let options;
163
+ try {
164
+ options = parseCleanupArgs(args);
165
+ } catch (error) {
166
+ console.error(`Pota cleanup error: ${firstLine(error)}`);
167
+ process.exitCode = 2;
168
+ return;
169
+ }
170
+ if (options.action === "help") {
171
+ console.log(usage);
172
+ return;
173
+ }
174
+ if (options.action === "version") {
175
+ const { version } = JSON.parse(readFileSync(join(resolvePluginRoot(import.meta.url), "package.json"), "utf8"));
176
+ console.log(`pota ${version}`);
177
+ return;
178
+ }
179
+ try {
180
+ const repoRoot = resolveRepoRoot({ explicitRoot: options.explicitRoot });
181
+ console.log(JSON.stringify(await cleanupProjectPota({ root: repoRoot, apply: options.apply }), null, 2));
182
+ } catch (error) {
183
+ console.error(`Pota cleanup error: ${firstLine(error)}`);
184
+ process.exitCode = 1;
185
+ }
186
+ }
187
+
188
+ function firstLine(error) {
189
+ return String(error instanceof Error ? error.message : error).split(/\r?\n/)[0];
190
+ }
191
+
192
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
193
+ await main(process.argv.slice(2));
194
+ }