@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,153 @@
1
+ import { createHash } from "node:crypto";
2
+ import { constants as fsConstants } from "node:fs";
3
+ import { lstat, open, readFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+
6
+ export function updateConfig(config, hookStates, { marketplacePath } = {}) {
7
+ if (marketplacePath === undefined) throw new Error("Pota marketplace path is required");
8
+ let next = removePotaConfig(config);
9
+ next = append(next, `[marketplaces.pota]\nlast_updated = ${JSON.stringify(new Date().toISOString().replace(/\.\d{3}Z$/, "Z"))}\nsource_type = "local"\nsource = ${JSON.stringify(marketplacePath)}\n`);
10
+ next = append(next, '[plugins."pota@pota"]\nenabled = true\n');
11
+ for (const state of hookStates) next = append(next, `[hooks.state.${JSON.stringify(state.key)}]\ntrusted_hash = ${JSON.stringify(state.trustedHash)}\n`);
12
+ return next;
13
+ }
14
+
15
+ export function removePotaConfig(config) {
16
+ return splitSections(config).map((section) => isPotaConfigSection(section)
17
+ ? splitTrailingTomlTrivia(section.text).trivia
18
+ : section.text).join("");
19
+ }
20
+
21
+ export function potaConfigProjection(config) {
22
+ return splitSections(config).filter(isPotaConfigSection)
23
+ .map((section) => splitTrailingTomlTrivia(section.text).body).join("");
24
+ }
25
+
26
+ function isPotaConfigSection(section) {
27
+ return section.header === "marketplaces.pota"
28
+ || section.header === 'plugins."pota@pota"'
29
+ || (section.header?.startsWith("hooks.state.") && section.header.includes("pota@pota:"));
30
+ }
31
+
32
+ function splitTrailingTomlTrivia(text) {
33
+ const lines = text.split(/(?<=\n)/);
34
+ let boundary = lines.length;
35
+ while (boundary > 0) {
36
+ const line = lines[boundary - 1];
37
+ if (line.length === 0 || line.trim().length === 0 || line.trimStart().startsWith("#")) boundary -= 1;
38
+ else break;
39
+ }
40
+ return { body: lines.slice(0, boundary).join(""), trivia: lines.slice(boundary).join("") };
41
+ }
42
+
43
+ export async function readSharedConfigSnapshot(path) {
44
+ let pathInfo;
45
+ try {
46
+ pathInfo = await lstat(path);
47
+ } catch (error) {
48
+ if (error?.code === "ENOENT") return { exists: false, text: "", digest: "missing" };
49
+ throw error;
50
+ }
51
+ if (pathInfo.isSymbolicLink() || !pathInfo.isFile()) throw new Error(`Managed config is not a regular file: ${path}`);
52
+ let handle;
53
+ try {
54
+ const flags = fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0);
55
+ handle = await open(path, flags);
56
+ } catch (error) {
57
+ if (["ELOOP", "EFTYPE", "ENXIO"].includes(error?.code)) throw new Error(`Managed config is not a regular file: ${path}`);
58
+ throw error;
59
+ }
60
+ try {
61
+ const openedInfo = await handle.stat();
62
+ if (!openedInfo.isFile() || openedInfo.dev !== pathInfo.dev || openedInfo.ino !== pathInfo.ino) {
63
+ throw new Error(`Managed config changed identity while opening: ${path}`);
64
+ }
65
+ const text = await handle.readFile("utf8");
66
+ return { exists: true, text, digest: sha(text) };
67
+ } finally {
68
+ await handle.close();
69
+ }
70
+ }
71
+
72
+ export function ensureSetting(config, sectionName, key, value) {
73
+ const sections = splitSections(config);
74
+ const index = sections.findIndex((section) => section.header === sectionName);
75
+ if (index === -1) return append(config, `[${sectionName}]\n${key} = ${value}\n`);
76
+ const lines = sections[index].text.trimEnd().split("\n");
77
+ const settingIndex = lines.findIndex((line) => line.trim().startsWith(`${key} =`));
78
+ if (settingIndex === -1) lines.push(`${key} = ${value}`);
79
+ else lines[settingIndex] = `${key} = ${value}`;
80
+ sections[index].text = `${lines.join("\n")}\n`;
81
+ return sections.map((section) => section.text).join("");
82
+ }
83
+
84
+ export function hasSetting(config, sectionName, key, value) {
85
+ const section = splitSections(config).find((candidate) => candidate.header === sectionName);
86
+ return section?.text.split("\n").some((line) => line.trim() === `${key} = ${value}`) ?? false;
87
+ }
88
+
89
+ export function splitSections(config) {
90
+ const result = [];
91
+ let current = { header: null, text: "" };
92
+ let multiline = null;
93
+ for (const line of config.split(/(?<=\n)/)) {
94
+ const delimiterCount = (delimiter) => line.split(delimiter).length - 1;
95
+ if (multiline) {
96
+ current.text += line;
97
+ if (delimiterCount(multiline) % 2 === 1) multiline = null;
98
+ continue;
99
+ }
100
+ const doubleCount = delimiterCount('"""');
101
+ const singleCount = delimiterCount("'''");
102
+ // Array-of-tables and headers with trailing comments start a new section too; otherwise a foreign section
103
+ // after Pota's would be absorbed into it and deleted on upgrade/uninstall.
104
+ const trimmed = line.trim();
105
+ const arrayMatch = trimmed.match(/^\[\[([^\[\]]+)\]\]\s*(?:#.*)?$/);
106
+ const match = arrayMatch ? null : trimmed.match(/^\[([^\[\]]+)\]\s*(?:#.*)?$/);
107
+ if (arrayMatch || match) {
108
+ if (current.text.length > 0) result.push(current);
109
+ current = { header: arrayMatch ? `[${arrayMatch[1].trim()}]` : match[1].trim(), text: line };
110
+ } else current.text += line;
111
+ if (doubleCount % 2 === 1) multiline = '"""';
112
+ else if (singleCount % 2 === 1) multiline = "'''";
113
+ }
114
+ if (current.text.length > 0) result.push(current);
115
+ return result;
116
+ }
117
+
118
+ export async function trustedHookStates(root) {
119
+ const parsed = JSON.parse(await readFile(join(root, "hooks", "hooks.json"), "utf8"));
120
+ const states = [];
121
+ const labels = { SessionStart: "session_start", UserPromptSubmit: "user_prompt_submit", PostToolUse: "post_tool_use", Stop: "stop", SubagentStop: "subagent_stop" };
122
+ for (const [eventName, groups] of Object.entries(parsed?.hooks ?? {})) {
123
+ const eventLabel = labels[eventName];
124
+ if (!eventLabel || !Array.isArray(groups)) continue;
125
+ for (const [groupIndex, group] of groups.entries()) for (const [handlerIndex, handler] of (group.hooks ?? []).entries()) {
126
+ states.push({ key: `pota@pota:hooks/hooks.json:${eventLabel}:${groupIndex}:${handlerIndex}`, trustedHash: hookHash(eventLabel, group.matcher, handler) });
127
+ }
128
+ }
129
+ return states;
130
+ }
131
+
132
+ export function hookHash(eventName, matcher, handler) {
133
+ const normalized = { type: "command", command: handler.command, timeout: Math.max(Number(handler.timeout ?? 600), 1), async: false };
134
+ if (typeof handler.statusMessage === "string") normalized.statusMessage = handler.statusMessage;
135
+ const identity = { event_name: eventName, hooks: [normalized] };
136
+ if (typeof matcher === "string") identity.matcher = matcher;
137
+ return sha(JSON.stringify(canonical(identity)));
138
+ }
139
+
140
+ export function canonical(value) {
141
+ if (Array.isArray(value)) return value.map(canonical);
142
+ if (value === null || typeof value !== "object") return value;
143
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])]));
144
+ }
145
+
146
+ function append(config, block) {
147
+ if (config.length === 0) return `${block.trimEnd()}\n`;
148
+ return `${config}${config.endsWith("\n") ? "" : "\n"}${block.trimEnd()}\n`;
149
+ }
150
+
151
+ function sha(value) {
152
+ return `sha256:${createHash("sha256").update(value).digest("hex")}`;
153
+ }
@@ -0,0 +1,98 @@
1
+ import { createInterface } from "node:readline";
2
+
3
+ // Hosts Pota can install into, in canonical order. The ownership manifest records a non-empty subset in this order.
4
+ export const HOSTS = ["codex", "claude", "antigravity"];
5
+ const HOST_LABELS = { codex: "Codex", claude: "Claude Code", antigravity: "Antigravity" };
6
+ const PROMPT_ATTEMPTS = 3;
7
+
8
+ export function isHostSelection(hosts) {
9
+ return Array.isArray(hosts) && hosts.length > 0 && hosts.length <= HOSTS.length
10
+ && hosts.every((host, index) => HOSTS.includes(host) && (index === 0 || HOSTS.indexOf(hosts[index - 1]) < HOSTS.indexOf(host)));
11
+ }
12
+
13
+ // `all` or a comma-separated list of host ids; the result is in canonical order. Missing, empty, unknown, and
14
+ // duplicate values are rejected so nothing is locked or staged for a selection the user did not mean.
15
+ export function parseHostsOption(value) {
16
+ if (typeof value !== "string" || value.length === 0) throw new Error(`Invalid --hosts value: ${value === undefined ? "missing" : "empty"}`);
17
+ if (value === "all") return [...HOSTS];
18
+ const names = value.split(",").map((name) => name.trim());
19
+ const seen = new Set();
20
+ for (const name of names) {
21
+ if (name.length === 0) throw new Error(`Invalid --hosts value: ${value} (empty host name)`);
22
+ if (!HOSTS.includes(name)) throw new Error(`Invalid --hosts value: ${value} (unknown host ${name}; use all or ${HOSTS.join(",")})`);
23
+ if (seen.has(name)) throw new Error(`Invalid --hosts value: ${value} (duplicate host ${name})`);
24
+ seen.add(name);
25
+ }
26
+ return HOSTS.filter((host) => seen.has(host));
27
+ }
28
+
29
+ // The host a managed or journal-only target belongs to; `core` targets are installed for every selection.
30
+ export function hostOfTarget(id) {
31
+ if (id === "runtime" || id === "ownership" || id.startsWith("retired-runtime-")) return "core";
32
+ if (["codex-source", "marketplace", "codex-runtime", "codex-config", "previous-codex-runtime"].includes(id)
33
+ || id.startsWith("retired-codex-runtime-")) return "codex";
34
+ if (id.startsWith("claude-")) return "claude";
35
+ if (/^(?:ide|cli)-/.test(id)) return "antigravity";
36
+ return null;
37
+ }
38
+
39
+ export function hostLabels(hosts) {
40
+ return hosts.map((host) => HOST_LABELS[host]).join(", ");
41
+ }
42
+
43
+ function promptText(ko) {
44
+ return ko ? [
45
+ "Pota를 설치할 호스트를 선택하세요.",
46
+ " 1) 전체 — Codex, Claude Code, Antigravity (기본)",
47
+ " 2) Claude Code만",
48
+ " 3) Codex만",
49
+ " 4) Antigravity만",
50
+ "번호를 입력하거나 호스트 이름을 쉼표로 구분해 입력하세요 (codex,claude,antigravity) [1]: ",
51
+ ] : [
52
+ "Select the hosts to install Pota into.",
53
+ " 1) All — Codex, Claude Code, Antigravity (default)",
54
+ " 2) Claude Code only",
55
+ " 3) Codex only",
56
+ " 4) Antigravity only",
57
+ "Enter a number, or host names separated by commas (codex,claude,antigravity) [1]: ",
58
+ ];
59
+ }
60
+
61
+ export function parseHostsAnswer(answer) {
62
+ const value = answer.trim();
63
+ const choices = { "": HOSTS, 1: HOSTS, 2: ["claude"], 3: ["codex"], 4: ["antigravity"] };
64
+ if (Object.hasOwn(choices, value)) return [...choices[value]];
65
+ return parseHostsOption(value);
66
+ }
67
+
68
+ // Asks once, re-asks after an invalid answer, and gives up after three attempts or at end of input. An empty answer
69
+ // selects every host. The caller asks before taking the lifecycle lock, so a waiting prompt never blocks another run.
70
+ export async function promptHosts({ input, output, ko = false }) {
71
+ const lines = promptText(ko);
72
+ const question = lines.at(-1);
73
+ const reader = createInterface({ input, terminal: false });
74
+ const answers = reader[Symbol.asyncIterator]();
75
+ try {
76
+ output.write(lines.join("\n"));
77
+ for (let attempt = 1; attempt <= PROMPT_ATTEMPTS; attempt += 1) {
78
+ if (attempt > 1) output.write(question);
79
+ const next = await answers.next();
80
+ if (next.done) {
81
+ output.write("\n");
82
+ break;
83
+ }
84
+ try {
85
+ const hosts = parseHostsAnswer(next.value);
86
+ output.write(ko
87
+ ? `선택한 호스트: ${hostLabels(hosts)}. 나중에 바꾸려면: pota install --hosts <목록>\n`
88
+ : `Selected hosts: ${hostLabels(hosts)}. To change later: pota install --hosts <list>\n`);
89
+ return hosts;
90
+ } catch {
91
+ output.write(ko ? `잘못된 입력입니다: ${next.value.trim()}\n` : `Invalid selection: ${next.value.trim()}\n`);
92
+ }
93
+ }
94
+ } finally {
95
+ reader.close();
96
+ }
97
+ throw new Error("No valid host selection was entered.");
98
+ }
@@ -0,0 +1,272 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstat, readFile, readdir } from "node:fs/promises";
3
+ import { basename, join, relative, sep } from "node:path";
4
+ import { potaConfigProjection, readSharedConfigSnapshot } from "./pota-lifecycle-config.mjs";
5
+ import { HOSTS, hostOfTarget, isHostSelection } from "./pota-lifecycle-hosts.mjs";
6
+ import { antigravityRuntimeLocatorPath } from "./pota-lifecycle-mirror.mjs";
7
+
8
+ export const OWNERSHIP_SCHEMA_VERSION = 2;
9
+ export const OWNERSHIP_ORIGINS = ["checkout", "package"];
10
+ // Runtimes a version change leaves behind stay for open sessions: at most this many per id, for this long.
11
+ export const RETAINED_RUNTIME_IDS = ["runtime", "codex-runtime"];
12
+ export const RETAINED_RUNTIME_LIMIT = 3;
13
+ export const RETAINED_RUNTIME_GRACE_MS = 3 * 24 * 60 * 60 * 1000;
14
+ const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
15
+
16
+ // Orders release versions by major.minor.patch; a prerelease suffix compares equal to its release.
17
+ export function compareVersions(left, right) {
18
+ const parse = (value) => String(value).split("-")[0].split(".").map(Number);
19
+ const [a, b] = [parse(left), parse(right)];
20
+ for (let index = 0; index < 3; index += 1) if (a[index] !== b[index]) return a[index] - b[index];
21
+ return 0;
22
+ }
23
+
24
+ export function createIntegrity(context) {
25
+ const {
26
+ antigravityCliHome, antigravityHome, claudeHome, claudePluginMarketplaceRoot, claudeRuntimeLocatorPath,
27
+ commandNames, configPath, installedPluginRoot, layout,
28
+ marketplaceJsonPath, ownershipPath, phases, pluginName,
29
+ releaseVersion, skillNames,
30
+ } = context;
31
+ // A journal-only removal (previous runtime) names the record it removes with `ownedBy` and must match
32
+ // that record's path, type, and digest. A record claimed that way moves away from its id, so the new target with the
33
+ // same id at a different path starts unowned. A retired-runtime removal carries its `retained` entry instead.
34
+ async function assertTargetsReplaceable(targets, ownership) {
35
+ const records = new Map((ownership?.targets ?? []).map((record) => [record.id, record]));
36
+ const moved = new Set(targets.filter((target) => target.ownedBy).map((target) => target.ownedBy));
37
+ for (const target of targets) {
38
+ if (!(await exists(target.path))) {
39
+ target.preflightDigest = "missing";
40
+ continue;
41
+ }
42
+ const current = await targetDigest(target.path, target.type);
43
+ target.preflightDigest = current;
44
+ if (target.ownedBy || target.retained) {
45
+ const owner = target.retained ? { ...target.retained, type: "tree" } : records.get(target.ownedBy);
46
+ if (!owner || owner.path !== target.path || owner.type !== target.type) throw new Error(`Removal target does not match ownership manifest: ${target.id}`);
47
+ if (target.type !== "config" && current !== owner.installedDigest) throw new Error(`Managed artifact was modified: ${target.path}`);
48
+ continue;
49
+ }
50
+ const claimed = records.get(target.id);
51
+ const record = claimed && !(moved.has(target.id) && claimed.path !== target.path) ? claimed : undefined;
52
+ const versionedRuntime = target.id === "runtime" || target.id === "codex-runtime";
53
+ // After a version change the release's runtime is owned only when an earlier upgrade retained it. A removal
54
+ // (a deselected host's record) is checked against its own record below.
55
+ if (versionedRuntime && target.operation !== "remove" && ownership && ownership.version !== releaseVersion) {
56
+ const retained = (ownership.retained ?? []).find((entry) => entry.id === target.id && entry.version === releaseVersion);
57
+ if (!retained || retained.path !== target.path || target.type !== "tree") {
58
+ throw new Error(`Refusing to overwrite unmanaged current-version runtime: ${target.path}`);
59
+ }
60
+ if (current !== retained.installedDigest) throw new Error(`Managed artifact was modified: ${target.path}`);
61
+ continue;
62
+ }
63
+ if (!record) {
64
+ if (target.type === "config" && potaConfigProjection((await readSharedConfigSnapshot(target.path)).text).length === 0) continue;
65
+ if (claimed && versionedRuntime) throw new Error(`Refusing to overwrite unmanaged current-version runtime: ${target.path}`);
66
+ throw new Error(`Refusing to overwrite unmanaged artifact: ${target.path}`);
67
+ }
68
+ if (record.path !== target.path || record.type !== target.type) throw new Error(`Ownership manifest target mismatch: ${target.id}`);
69
+ if (target.type !== "config" && current !== record.installedDigest) throw new Error(`Managed artifact was modified: ${target.path}`);
70
+ }
71
+ }
72
+
73
+ async function assertOwnershipDigests(ownership) {
74
+ for (const record of [...ownership.targets, ...(ownership.retained ?? []).map((entry) => ({ ...entry, type: "tree" }))]) {
75
+ const current = await targetDigest(record.path, record.type);
76
+ if (record.type !== "config" && current !== record.installedDigest) {
77
+ throw new Error(`Managed artifact was modified: ${record.path}`);
78
+ }
79
+ }
80
+ }
81
+
82
+ // A retained runtime that no longer exists was removed outside Pota (by Codex or the user) and counts as already
83
+ // gone: install, upgrade, and uninstall drop it from the manifest instead of removing or recording it, and doctor does
84
+ // not report it. One that exists with other content is still a modified managed artifact.
85
+ async function withoutMissingRetained(ownership) {
86
+ if (!Array.isArray(ownership?.retained)) return ownership;
87
+ const retained = [];
88
+ for (const entry of ownership.retained) if (await exists(entry.path)) retained.push(entry);
89
+ return { ...ownership, retained };
90
+ }
91
+
92
+ async function targetRecord(target) {
93
+ return { id: target.id, phase: target.phase, path: target.path, type: target.type, installedDigest: await targetDigest(target.path, target.type), previous: null };
94
+ }
95
+
96
+ async function readOwnership({ required = false } = {}) {
97
+ if (!(await exists(ownershipPath))) {
98
+ if (required) throw new Error(`Ownership manifest is missing: ${ownershipPath}`);
99
+ return null;
100
+ }
101
+ try {
102
+ return JSON.parse(await readFile(ownershipPath, "utf8"));
103
+ } catch {
104
+ throw new Error(`Ownership manifest is invalid: ${ownershipPath}`);
105
+ }
106
+ }
107
+
108
+ function validateOwnershipShape(ownership, { required = false } = {}) {
109
+ if (!ownership) {
110
+ if (required) throw new Error("Ownership manifest is required.");
111
+ return;
112
+ }
113
+ if (!sameKeys(ownership, ["schemaVersion", "plugin", "version", "transactionId", "installedAt", "origin", "hosts", "targets", "retained"])
114
+ || ownership.schemaVersion !== OWNERSHIP_SCHEMA_VERSION || ownership.plugin !== pluginName
115
+ || typeof ownership.version !== "string" || !VERSION_PATTERN.test(ownership.version)
116
+ || typeof ownership.transactionId !== "string" || !/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i.test(ownership.transactionId)
117
+ || typeof ownership.installedAt !== "string" || Number.isNaN(Date.parse(ownership.installedAt))
118
+ || !OWNERSHIP_ORIGINS.includes(ownership.origin)
119
+ || !isHostSelection(ownership.hosts)
120
+ || !Array.isArray(ownership.targets) || !Array.isArray(ownership.retained)) {
121
+ throw new Error("Ownership manifest schema or plugin identity is invalid.");
122
+ }
123
+ const retainedKeys = new Set();
124
+ for (const record of ownership.retained ?? []) {
125
+ if (!record || !sameKeys(record, ["id", "version", "path", "installedDigest", "retiredAt"])
126
+ || !RETAINED_RUNTIME_IDS.includes(record.id) || typeof record.version !== "string" || !VERSION_PATTERN.test(record.version)
127
+ || record.version === ownership.version || retainedKeys.has(`${record.id}\0${record.version}`)
128
+ || record.path !== retainedRuntimePath(record.id, record.version)
129
+ || typeof record.installedDigest !== "string" || !/^sha256:[a-f0-9]{64}$/.test(record.installedDigest)
130
+ || typeof record.retiredAt !== "string" || Number.isNaN(Date.parse(record.retiredAt))
131
+ || ownership.retained.filter((entry) => entry?.id === record.id).length > RETAINED_RUNTIME_LIMIT
132
+ || (record.id === "codex-runtime" && !ownership.hosts.includes("codex"))) {
133
+ throw new Error("Ownership manifest contains an invalid retained runtime.");
134
+ }
135
+ retainedKeys.add(`${record.id}\0${record.version}`);
136
+ }
137
+ const ids = new Set();
138
+ const paths = new Set();
139
+ for (const record of ownership.targets) {
140
+ if (!record || !sameKeys(record, ["id", "phase", "path", "type", "installedDigest", "previous"])
141
+ || typeof record.id !== "string" || record.id.length === 0 || ids.has(record.id)
142
+ || typeof record.path !== "string" || record.path.length === 0 || paths.has(record.path)
143
+ || typeof record.phase !== "string" || !phases.includes(record.phase)
144
+ || typeof record.type !== "string" || !["file", "tree", "config"].includes(record.type)
145
+ || typeof record.installedDigest !== "string" || !/^sha256:[a-f0-9]{64}$/.test(record.installedDigest)
146
+ || record.previous !== null) {
147
+ throw new Error("Ownership manifest contains an invalid or duplicate target.");
148
+ }
149
+ const expected = expectedManagedTarget(record.id, ownership.version);
150
+ if (!expected || expected.path !== record.path || expected.phase !== record.phase || expected.type !== record.type) {
151
+ throw new Error(`Ownership manifest target is outside the Pota allowlist: ${record.id}`);
152
+ }
153
+ ids.add(record.id);
154
+ paths.add(record.path);
155
+ }
156
+ const hosts = ownership.hosts;
157
+ const unselected = [...ids].find((id) => !hosts.includes(hostOfTarget(id)) && hostOfTarget(id) !== "core");
158
+ if (unselected) throw new Error(`Ownership manifest schema is inconsistent: ${unselected} belongs to a host that is not selected.`);
159
+ const requiredTargets = requiredOwnershipTargets(ownership.version, hosts);
160
+ if ([...ids].some((id) => !requiredTargets.has(id)) || [...requiredTargets.keys()].some((id) => !ids.has(id))) {
161
+ throw new Error("Ownership manifest does not contain the complete managed target set.");
162
+ }
163
+ }
164
+
165
+ function retainedRuntimePath(id, version) {
166
+ return id === "runtime" ? layout.runtimeRootFor(version) : layout.codexRuntimeRootFor(version);
167
+ }
168
+
169
+ // The core runtime is always required; every other required target belongs to one selected host.
170
+ function requiredOwnershipTargets(version = releaseVersion, hosts = HOSTS) {
171
+ const ids = ["runtime", "codex-source", "codex-runtime", "marketplace", "claude-runtime", "claude-plugin", "ide-runtime", "cli-runtime", "codex-config"];
172
+ for (const name of commandNames()) ids.push(`claude-command-${name}`, `ide-workflow-${name}`, `cli-workflow-${name}`);
173
+ for (const name of ["explorer", "worker"]) ids.push(`claude-agent-${name}`);
174
+ for (const name of skillNames()) ids.push(`ide-skill-${name}`, `cli-skill-${name}`);
175
+ const selected = ids.filter((id) => hostOfTarget(id) === "core" || hosts.includes(hostOfTarget(id)));
176
+ return new Map(selected.map((id) => [id, expectedManagedTarget(id, version)]));
177
+ }
178
+
179
+ function expectedManagedTarget(id, version = releaseVersion) {
180
+ const exact = {
181
+ runtime: { phase: "core", path: layout.runtimeRootFor(version), type: "tree" },
182
+ "codex-source": { phase: "codex", path: installedPluginRoot, type: "tree" },
183
+ "codex-runtime": { phase: "codex", path: layout.codexRuntimeRootFor(version), type: "tree" },
184
+ marketplace: { phase: "codex", path: marketplaceJsonPath, type: "file" },
185
+ "claude-runtime": { phase: "claude", path: claudeRuntimeLocatorPath, type: "file" },
186
+ "claude-plugin": { phase: "claude", path: claudePluginMarketplaceRoot, type: "tree" },
187
+ "ide-runtime": { phase: "antigravity", path: antigravityRuntimeLocatorPath(antigravityHome), type: "file" },
188
+ "cli-runtime": { phase: "antigravity", path: antigravityRuntimeLocatorPath(antigravityCliHome), type: "file" },
189
+ "codex-config": { phase: "config", path: configPath, type: "config" },
190
+ };
191
+ if (exact[id]) return exact[id];
192
+ for (const name of commandNames()) {
193
+ if (id === `claude-command-${name}`) return { phase: "claude", path: join(claudeHome, "commands", `pota-${name}.md`), type: "file" };
194
+ if (id === `ide-workflow-${name}`) return { phase: "antigravity", path: join(antigravityHome, "workflows", `pota-${name}.md`), type: "file" };
195
+ if (id === `cli-workflow-${name}`) return { phase: "antigravity", path: join(antigravityCliHome, "workflows", `pota-${name}.md`), type: "file" };
196
+ }
197
+ for (const name of ["explorer", "worker"]) if (id === `claude-agent-${name}`) return { phase: "claude", path: join(claudeHome, "agents", `pota-${name}.md`), type: "file" };
198
+ for (const name of skillNames()) {
199
+ if (id === `ide-skill-${name}`) return { phase: "antigravity", path: join(antigravityHome, "skills", name), type: "tree" };
200
+ if (id === `cli-skill-${name}`) return { phase: "antigravity", path: join(antigravityCliHome, "skills", `${name}.md`), type: "file" };
201
+ }
202
+ return null;
203
+ }
204
+
205
+ async function targetDigest(path, type) {
206
+ if (!(await exists(path))) return "missing";
207
+ if (type === "config") return sha(potaConfigProjection((await readSharedConfigSnapshot(path)).text));
208
+ const info = await lstat(path);
209
+ if (info.isSymbolicLink() || (!info.isFile() && !info.isDirectory())) throw new Error(`Managed artifact is not a regular file/tree: ${path}`);
210
+ if (info.isFile()) return sha(await readFile(path));
211
+ const files = await regularFiles(path);
212
+ const hash = createHash("sha256");
213
+ for (const file of files) {
214
+ hash.update(file).update("\0").update(await readFile(join(path, file))).update("\0");
215
+ }
216
+ return `sha256:${hash.digest("hex")}`;
217
+ }
218
+
219
+ async function regularFiles(root) {
220
+ const output = [];
221
+ async function walk(directory) {
222
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
223
+ const path = join(directory, entry.name);
224
+ if (entry.isSymbolicLink() || (!entry.isDirectory() && !entry.isFile())) throw new Error(`Symlink or special file is not allowed in managed tree: ${path}`);
225
+ if (entry.isDirectory()) await walk(path);
226
+ else output.push(relative(root, path).split(sep).join("/"));
227
+ }
228
+ }
229
+ const info = await lstat(root);
230
+ if (info.isSymbolicLink()) throw new Error(`Symlink is not allowed: ${root}`);
231
+ if (info.isFile()) return [basename(root)];
232
+ await walk(root);
233
+ return output.sort();
234
+ }
235
+
236
+ async function assertRegularTree(path) {
237
+ await regularFiles(path);
238
+ }
239
+
240
+ function sha(value) {
241
+ return `sha256:${createHash("sha256").update(value).digest("hex")}`;
242
+ }
243
+
244
+ async function ownershipDigestsValid(ownership) {
245
+ try {
246
+ validateOwnershipShape(ownership, { required: true });
247
+ for (const record of ownership.targets) if (await targetDigest(record.path, record.type) !== record.installedDigest) return false;
248
+ for (const record of ownership.retained ?? []) {
249
+ const digest = await targetDigest(record.path, "tree");
250
+ if (digest !== "missing" && digest !== record.installedDigest) return false;
251
+ }
252
+ return true;
253
+ } catch {
254
+ return false;
255
+ }
256
+ }
257
+
258
+ return {
259
+ assertOwnershipDigests, assertRegularTree,
260
+ assertTargetsReplaceable, expectedManagedTarget, ownershipDigestsValid, readOwnership,
261
+ targetDigest, targetRecord, validateOwnershipShape, withoutMissingRetained,
262
+ };
263
+
264
+ function sameKeys(value, expected) {
265
+ return Object.keys(value).sort().join("\0") === [...expected].sort().join("\0");
266
+ }
267
+
268
+ async function exists(path) {
269
+ try { await lstat(path); return true; }
270
+ catch (error) { if (error?.code === "ENOENT") return false; throw error; }
271
+ }
272
+ }
@@ -0,0 +1,52 @@
1
+ import { homedir } from "node:os";
2
+ import { join, resolve } from "node:path";
3
+ import { potaHome, installRoot as potaInstallRoot, installManifestPath } from "./pota-paths.mjs";
4
+
5
+ const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
6
+
7
+ // Path constants for the lifecycle: everything lives under <potaHome>/install, except the Codex plugin cache.
8
+ // Codex only treats pota@pota as installed when its own plugin cache holds pota/<version>, so that host
9
+ // cache stays under CODEX_HOME.
10
+ export function createLayout({ env = process.env, releaseVersion, marketplaceName = "pota", pluginName = "pota" }) {
11
+ if (typeof releaseVersion !== "string" || !VERSION_PATTERN.test(releaseVersion)) throw new Error(`Invalid Pota release version: ${releaseVersion}`);
12
+ const homeRoot = env.HOME ?? env.USERPROFILE ?? homedir() ?? ".";
13
+ const codexHome = resolve(env.CODEX_HOME ?? join(homeRoot, ".codex"));
14
+ const potaHomeRoot = potaHome(env);
15
+ const codexCacheRoot = join(codexHome, "plugins", "cache", marketplaceName);
16
+ const codexRuntimeParent = join(codexCacheRoot, pluginName);
17
+ const shared = { potaHomeRoot, codexHome, codexCacheRoot, codexRuntimeParent };
18
+
19
+ const installRoot = potaInstallRoot(env);
20
+ const codexMarketplaceRoot = join(installRoot, "codex");
21
+ return layoutPaths({
22
+ ...shared,
23
+ installRoot,
24
+ stateRoot: installRoot,
25
+ ownershipPath: installManifestPath(env),
26
+ marketplaceRoot: codexMarketplaceRoot,
27
+ installedPluginRoot: join(codexMarketplaceRoot, "plugins", pluginName),
28
+ marketplaceJsonPath: join(codexMarketplaceRoot, ".agents", "plugins", "marketplace.json"),
29
+ claudePluginMarketplaceRoot: join(installRoot, "claude"),
30
+ runtimeParent: join(installRoot, "runtime"),
31
+ }, releaseVersion);
32
+ }
33
+
34
+ function layoutPaths(paths, releaseVersion) {
35
+ const runtimeRootFor = (version) => versionedChild(paths.runtimeParent, version);
36
+ const codexRuntimeRootFor = (version) => versionedChild(paths.codexRuntimeParent, version);
37
+ return {
38
+ ...paths,
39
+ transactionPath: join(paths.stateRoot, "transaction.json"),
40
+ transactionRoot: join(paths.stateRoot, "transactions"),
41
+ lifecycleLockPath: join(paths.stateRoot, "lifecycle.lock"),
42
+ runtimeRoot: runtimeRootFor(releaseVersion),
43
+ codexRuntimeRoot: codexRuntimeRootFor(releaseVersion),
44
+ runtimeRootFor,
45
+ codexRuntimeRootFor,
46
+ };
47
+ }
48
+
49
+ function versionedChild(parent, version) {
50
+ if (typeof version !== "string" || !VERSION_PATTERN.test(version)) throw new Error(`Invalid Pota runtime version: ${version}`);
51
+ return join(parent, version);
52
+ }