superpowers-manager 0.4.0 → 0.5.1

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 (44) hide show
  1. package/README.md +76 -517
  2. package/dist/artifact-tree.js +111 -0
  3. package/dist/atomic.js +103 -7
  4. package/dist/cli-arguments.js +37 -0
  5. package/dist/cli.js +49 -12
  6. package/dist/commands/install.js +116 -17
  7. package/dist/commands/mutation.js +71 -0
  8. package/dist/commands/prepare.js +23 -17
  9. package/dist/commands/probe.js +164 -14
  10. package/dist/commands/uninstall.js +9 -5
  11. package/dist/commands/update.js +6 -2
  12. package/dist/domain/refs.js +4 -0
  13. package/dist/harness-compatibility.js +13 -0
  14. package/dist/{adapter.js → harnesses/codex/adapter.js} +17 -13
  15. package/dist/harnesses/codex/compatibility.js +126 -0
  16. package/dist/harnesses/codex/conflicts.js +99 -0
  17. package/dist/{generated-plugin.js → harnesses/codex/generated-plugin.js} +7 -73
  18. package/dist/{codex-harness.js → harnesses/codex/harness.js} +55 -5
  19. package/dist/{hooks.js → harnesses/codex/hooks.js} +6 -6
  20. package/dist/{codex-json.js → harnesses/codex/json.js} +9 -2
  21. package/dist/{lifecycle.js → harnesses/codex/lifecycle.js} +2 -2
  22. package/dist/{manifest-overlay.js → harnesses/codex/manifest-overlay.js} +5 -5
  23. package/dist/{codex-prepare.js → harnesses/codex/prepare.js} +72 -16
  24. package/dist/{codex-presentation.js → harnesses/codex/presentation.js} +27 -4
  25. package/dist/{codex-state.js → harnesses/codex/state.js} +3 -3
  26. package/dist/harnesses/pi/compatibility.js +73 -0
  27. package/dist/harnesses/pi/harness.js +63 -0
  28. package/dist/harnesses/pi/install.js +495 -0
  29. package/dist/harnesses/pi/native.js +81 -0
  30. package/dist/harnesses/pi/package.js +153 -0
  31. package/dist/harnesses/pi/paths.js +30 -0
  32. package/dist/harnesses/pi/prepare.js +111 -0
  33. package/dist/harnesses/pi/presentation.js +67 -0
  34. package/dist/harnesses/pi/settings.js +171 -0
  35. package/dist/harnesses/pi/shared-skills.js +413 -0
  36. package/dist/harnesses/pi/state.js +311 -0
  37. package/dist/python-text.js +16 -0
  38. package/dist/resource-lock.js +265 -0
  39. package/dist/safe-path.js +21 -5
  40. package/dist/skill-validation.js +52 -0
  41. package/dist/upstream-workspace.js +8 -0
  42. package/dist/validate-generated-plugin-cli.js +1 -1
  43. package/dist/validator.js +2 -1
  44. package/package.json +12 -8
@@ -0,0 +1,126 @@
1
+ import { join } from "node:path";
2
+ import { validateNativeSkill } from "../../artifact-tree.js";
3
+ import { classifyPathNoFollow, assertExistingContained, } from "../../safe-path.js";
4
+ import { classifyHooks, readManifest } from "./hooks.js";
5
+ import { SEMVER_RE } from "../../domain/refs.js";
6
+ import { validateSource } from "../../selection.js";
7
+ import { writeFile } from "node:fs/promises";
8
+ import { ARTIFACT_DIGEST_RE, ARTIFACT_RECEIPT, digestArtifactTree, readArtifactObject, } from "../../artifact-tree.js";
9
+ import { COMMIT_RE } from "../../domain/refs.js";
10
+ import { validateGeneratedPlugin } from "./generated-plugin.js";
11
+ import { SafetyError } from "../../safety-error.js";
12
+ export async function writeCodexAssessment(root, selection, manifestSource) {
13
+ const receipt = {
14
+ schema: 1,
15
+ manager: "superpowers-manager",
16
+ harness: "codex",
17
+ source: selection.effectiveSource,
18
+ commit: selection.desiredCommit,
19
+ generation: "codex-native-skills-v1",
20
+ manifestSource,
21
+ digest: await digestArtifactTree(root),
22
+ };
23
+ await writeFile(join(root, ARTIFACT_RECEIPT), JSON.stringify(receipt) + "\n", { flag: "wx" });
24
+ }
25
+ export async function readCodexAssessment(root) {
26
+ try {
27
+ const receipt = await readArtifactObject(root, join(root, ARTIFACT_RECEIPT));
28
+ const provenance = await readArtifactObject(root, join(root, ".superpowers-upstream.json"));
29
+ if (receipt.schema !== 1 ||
30
+ receipt.manager !== "superpowers-manager" ||
31
+ receipt.harness !== "codex" ||
32
+ receipt.generation !== "codex-native-skills-v1" ||
33
+ typeof receipt.source !== "string" ||
34
+ typeof receipt.commit !== "string" ||
35
+ !COMMIT_RE.test(receipt.commit) ||
36
+ typeof receipt.digest !== "string" ||
37
+ !ARTIFACT_DIGEST_RE.test(receipt.digest) ||
38
+ (receipt.manifestSource !== "upstream" &&
39
+ receipt.manifestSource !== "fallback") ||
40
+ receipt.source !== provenance.source ||
41
+ receipt.commit !== provenance.commit)
42
+ throw new Error("assessment fields");
43
+ validateSource(receipt.source);
44
+ if ((await digestArtifactTree(root)) !== receipt.digest)
45
+ throw new Error("assessment digest");
46
+ const manifest = await readManifest(join(root, ".codex-plugin/plugin.json"));
47
+ if (typeof manifest.version !== "string" ||
48
+ typeof provenance.requested_ref !== "string" ||
49
+ typeof provenance.resolved_ref !== "string" ||
50
+ typeof provenance.upstream_manifest_version !== "string")
51
+ throw new Error("provenance fields");
52
+ const errors = await validateGeneratedPlugin({
53
+ pluginRoot: root,
54
+ source: receipt.source,
55
+ commit: receipt.commit,
56
+ requestedRef: provenance.requested_ref,
57
+ resolvedRef: provenance.resolved_ref,
58
+ upstreamManifestVersion: provenance.upstream_manifest_version,
59
+ manifestVersion: manifest.version,
60
+ manifestSource: receipt.manifestSource,
61
+ });
62
+ if (errors.length)
63
+ throw new Error("generated resources");
64
+ const selection = {
65
+ effectiveSource: receipt.source,
66
+ desiredCommit: receipt.commit,
67
+ requestedRef: provenance.requested_ref,
68
+ resolvedRef: provenance.resolved_ref,
69
+ resolutionKind: "raw-commit",
70
+ selectionOrigin: "package-default",
71
+ selectionMode: "default",
72
+ upstreamSourceOrigin: "package-default",
73
+ saved: {
74
+ saved_mode: "none",
75
+ saved_source: "",
76
+ saved_requested_ref: "",
77
+ saved_resolved_ref: "",
78
+ saved_commit: "",
79
+ },
80
+ };
81
+ const compatibility = await assessCodexCompatibility(root, selection);
82
+ return {
83
+ root,
84
+ commit: receipt.commit,
85
+ identity: receipt.commit,
86
+ compatibility,
87
+ };
88
+ }
89
+ catch (cause) {
90
+ throw new SafetyError("codex-compatibility", `invalid Codex prepared assessment: ${root}`, { cause });
91
+ }
92
+ }
93
+ export async function assessCodexCompatibility(root, selection) {
94
+ try {
95
+ validateSource(selection.effectiveSource);
96
+ await validateNativeSkill(root);
97
+ for (const path of [
98
+ ".codex/superpowers-codex",
99
+ ".codex/superpowers-bootstrap.md",
100
+ ]) {
101
+ if ((await classifyPathNoFollow(join(root, path))) !== "missing")
102
+ throw new Error("legacy bootstrap");
103
+ }
104
+ const path = join(root, ".codex-plugin/plugin.json");
105
+ if ((await classifyPathNoFollow(path)) !== "missing") {
106
+ await assertExistingContained(root, path);
107
+ const manifest = await readManifest(path);
108
+ if (manifest.name !== "superpowers" ||
109
+ typeof manifest.version !== "string" ||
110
+ !SEMVER_RE.test(manifest.version))
111
+ throw new Error("manifest");
112
+ await classifyHooks(manifest, "upstream", root);
113
+ }
114
+ return {
115
+ kind: "supported",
116
+ generation: "codex-native-skills-v1",
117
+ reason: "native Codex skill discovery",
118
+ };
119
+ }
120
+ catch {
121
+ return {
122
+ kind: "unsupported",
123
+ reason: `Codex package does not provide compatible native skill discovery: ${root}`,
124
+ };
125
+ }
126
+ }
@@ -0,0 +1,99 @@
1
+ import { lstat, readFile, realpath, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { codexInstalledPluginsFromJson, } from "./json.js";
4
+ export const CODEX_MANAGER_PLUGIN_ID = "superpowers@superpowers-manager";
5
+ export const CODEX_LEGACY_PLUGIN_ID = "superpowers@superpowers-wrapper";
6
+ const SUPERPOWERS_PLUGIN_PREFIX = "superpowers@";
7
+ const DISPLAYABLE_PLUGIN_ID = /^superpowers@[A-Za-z0-9][A-Za-z0-9._-]*/;
8
+ const NATIVE_ROUTE = ".agents/skills/superpowers";
9
+ const NATIVE_ROUTE_CONFLICT = `native Codex skills route ~/${NATIVE_ROUTE} has indeterminate activity`;
10
+ function isMissingPath(cause) {
11
+ if (cause === null || typeof cause !== "object" || !("code" in cause)) {
12
+ return false;
13
+ }
14
+ return cause.code === "ENOENT" || cause.code === "ENOTDIR";
15
+ }
16
+ function isUnmanagedPluginId(pluginId) {
17
+ if (pluginId === CODEX_MANAGER_PLUGIN_ID ||
18
+ pluginId === CODEX_LEGACY_PLUGIN_ID) {
19
+ return false;
20
+ }
21
+ return (pluginId.startsWith(SUPERPOWERS_PLUGIN_PREFIX) &&
22
+ pluginId.length > SUPERPOWERS_PLUGIN_PREFIX.length);
23
+ }
24
+ function displayablePluginId(pluginId) {
25
+ return pluginId.length <= 256 &&
26
+ DISPLAYABLE_PLUGIN_ID.exec(pluginId)?.[0] === pluginId
27
+ ? pluginId
28
+ : "";
29
+ }
30
+ function unmanagedPluginConflict(plugin) {
31
+ if (!isUnmanagedPluginId(plugin.pluginId))
32
+ return "";
33
+ const displayId = displayablePluginId(plugin.pluginId);
34
+ const subject = displayId.length > 0
35
+ ? `Codex plugin ${displayId}`
36
+ : "Codex plugin with a non-displayable Superpowers identity";
37
+ if (plugin.installed === true && plugin.enabled === true) {
38
+ return `active ${subject}`;
39
+ }
40
+ if ((plugin.installed === true && plugin.enabled === false) ||
41
+ (plugin.installed === false && plugin.enabled === false)) {
42
+ return "";
43
+ }
44
+ return `${subject} has indeterminate activity`;
45
+ }
46
+ async function nativeRouteConflict(ctx) {
47
+ const home = ctx.env?.HOME;
48
+ if (home === undefined || home.length === 0)
49
+ return NATIVE_ROUTE_CONFLICT;
50
+ const route = join(home, ".agents", "skills", "superpowers");
51
+ const asset = join(route, "using-superpowers", "SKILL.md");
52
+ let routeIsSymlink;
53
+ try {
54
+ routeIsSymlink = (await lstat(route)).isSymbolicLink();
55
+ }
56
+ catch (cause) {
57
+ return isMissingPath(cause) ? "" : NATIVE_ROUTE_CONFLICT;
58
+ }
59
+ if (!routeIsSymlink) {
60
+ try {
61
+ await lstat(asset);
62
+ }
63
+ catch (cause) {
64
+ return isMissingPath(cause) ? "" : NATIVE_ROUTE_CONFLICT;
65
+ }
66
+ }
67
+ try {
68
+ const resolvedAsset = await realpath(asset);
69
+ if (!(await stat(resolvedAsset)).isFile())
70
+ return NATIVE_ROUTE_CONFLICT;
71
+ const contents = await readFile(resolvedAsset, "utf8");
72
+ const lines = contents.split(/\r?\n/u);
73
+ if (lines[0] !== "---")
74
+ return "";
75
+ const end = lines.indexOf("---", 1);
76
+ if (end < 0 ||
77
+ !lines
78
+ .slice(1, end)
79
+ .some((line) => /^name:[ \t]+using-superpowers[ \t]*$/u.test(line))) {
80
+ return "";
81
+ }
82
+ }
83
+ catch {
84
+ return NATIVE_ROUTE_CONFLICT;
85
+ }
86
+ return NATIVE_ROUTE_CONFLICT;
87
+ }
88
+ export async function inspectCodexConflicts(ctx, installedListing) {
89
+ const conflicts = new Set();
90
+ for (const plugin of codexInstalledPluginsFromJson(installedListing)) {
91
+ const conflict = unmanagedPluginConflict(plugin);
92
+ if (conflict.length > 0)
93
+ conflicts.add(conflict);
94
+ }
95
+ const nativeConflict = await nativeRouteConflict(ctx);
96
+ if (nativeConflict.length > 0)
97
+ conflicts.add(nativeConflict);
98
+ return [...conflicts];
99
+ }
@@ -1,20 +1,9 @@
1
- import { lstat, readdir, readFile, readlink, stat } from "node:fs/promises";
2
1
  import { homedir } from "node:os";
3
2
  import { posix } from "node:path";
4
- import { COMMIT_RE } from "./domain/refs.js";
5
- import { pythonSplitlines, pythonStrip } from "./python-text.js";
6
- import { parseStrictJson } from "./strict-json.js";
7
- export const DEFAULT_FS_DEPS = {
8
- lstat,
9
- stat,
10
- readdir,
11
- readlink,
12
- readFile,
13
- };
14
- // The Python's own SEMVER_RE (:15-22). Deliberately not SEMVER_BASE_SOURCE
15
- // from src/domain/refs.ts, which omits the `+build` component; widening the
16
- // shared constant would change TAG_RE and is out of scope.
17
- const SEMVER_RE = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
3
+ import { COMMIT_RE, SEMVER_RE } from "../../domain/refs.js";
4
+ import { compareByCodePoint, pythonStrip } from "../../python-text.js";
5
+ import { DEFAULT_FS_DEPS, validateSkillFrontmatter, } from "../../skill-validation.js";
6
+ import { parseStrictJson } from "../../strict-json.js";
18
7
  const MANIFEST_JSON_PROFILE = {
19
8
  duplicateKeys: "last-wins",
20
9
  nonStandardConstants: "reject",
@@ -443,61 +432,6 @@ const REQUIRED_FILES = [
443
432
  "README.md",
444
433
  "CODE_OF_CONDUCT.md",
445
434
  ];
446
- /** Python `sorted()` orders by code point; JavaScript's default sort does not. */
447
- function compareByCodePoint(left, right) {
448
- // `Array.from` splits by code point, exactly as spreading would; oxlint's
449
- // `no-misused-spread` rejects the spread form, and grapheme segmentation is
450
- // the wrong unit here — Python compares code points.
451
- const leftPoints = Array.from(left);
452
- const rightPoints = Array.from(right);
453
- const shared = Math.min(leftPoints.length, rightPoints.length);
454
- for (let index = 0; index < shared; index += 1) {
455
- const a = leftPoints[index].codePointAt(0);
456
- const b = rightPoints[index].codePointAt(0);
457
- if (a !== b)
458
- return a - b;
459
- }
460
- return leftPoints.length - rightPoints.length;
461
- }
462
- async function validateSkillFrontmatter(skillMd, skillName, errors, deps) {
463
- let contents;
464
- try {
465
- contents = STRICT_DECODER.decode(await deps.readFile(skillMd));
466
- }
467
- catch {
468
- errors.push(`skill \`${skillName}\` has unreadable UTF-8 \`SKILL.md\``);
469
- return;
470
- }
471
- if (contents === "") {
472
- errors.push(`skill \`${skillName}\` has empty \`SKILL.md\``);
473
- return;
474
- }
475
- const lines = pythonSplitlines(contents);
476
- if (lines.length === 0 || lines[0] !== "---") {
477
- errors.push(`skill \`${skillName}\` must start with \`---\``);
478
- return;
479
- }
480
- const closingIndex = lines.indexOf("---", 1);
481
- if (closingIndex === -1) {
482
- errors.push(`skill \`${skillName}\` frontmatter is not closed`);
483
- return;
484
- }
485
- const frontmatter = lines.slice(1, closingIndex);
486
- for (const key of ["name", "description"]) {
487
- const matches = frontmatter.filter((line) => line.startsWith(`${key}:`));
488
- if (matches.length !== 1) {
489
- errors.push(`skill \`${skillName}\` frontmatter must contain exactly one top-level \`${key}:\``);
490
- continue;
491
- }
492
- const value = pythonStrip(matches[0].slice(matches[0].indexOf(":") + 1));
493
- if (value === "" ||
494
- value === "''" ||
495
- value === '""' ||
496
- value.startsWith("#")) {
497
- errors.push(`skill \`${skillName}\` frontmatter field \`${key}\` must be non-empty`);
498
- }
499
- }
500
- }
501
435
  async function validateHookSubtree(pluginRoot, hooksRoot, errors, deps) {
502
436
  let resolvedRoot;
503
437
  try {
@@ -513,9 +447,9 @@ async function validateHookSubtree(pluginRoot, hooksRoot, errors, deps) {
513
447
  isLink = (await inspectLink(path, deps)) === "symlink";
514
448
  }
515
449
  catch {
516
- // The `src/generated-plugin.ts:667::isLink =` probe reaches the first catch bounded by `src/generated-plugin.ts:672-676::let rawTarget`; the `src/generated-plugin.ts:678::rawTarget = decodePathBytes` readlink reaches the same site text.
517
- // The three probes sharing the *subtree* string are `src/generated-plugin.ts:711::resolvedDirectory =`, `src/generated-plugin.ts:721::children =`, and
518
- // `src/generated-plugin.ts:730::deps, true)) === "directory"`, not this one.
450
+ // The `src/harnesses/codex/generated-plugin.ts:583::isLink =` probe reaches the first catch bounded by `src/harnesses/codex/generated-plugin.ts:592::let rawTarget`; the `src/harnesses/codex/generated-plugin.ts:594::rawTarget = decodePathBytes` readlink reaches the same site text.
451
+ // The three probes sharing the *subtree* string are `src/harnesses/codex/generated-plugin.ts:627::resolvedDirectory =`, `src/harnesses/codex/generated-plugin.ts:637::children =`, and
452
+ // `src/harnesses/codex/generated-plugin.ts:646::deps, true)) === "directory"`, not this one.
519
453
  errors.push(`generated hook symlink could not be inspected: ${path}`);
520
454
  return false;
521
455
  }
@@ -1,9 +1,11 @@
1
- import { failureResult, hasTerminalControl, successResult, } from "./adapter-result.js";
1
+ import { join, resolve } from "node:path";
2
+ import { failureResult, hasTerminalControl, successResult, } from "../../adapter-result.js";
2
3
  import { codexInspect, codexInstall, codexRemove, } from "./adapter.js";
3
- import { codexPreparationLocation, inspectCodexPrepared, prepareCodexCandidate, readCodexPrepared, validateCodexPreparationBeforeFetch, } from "./codex-prepare.js";
4
- import { codexInstallReceipt, codexPresentation, } from "./codex-presentation.js";
4
+ import { codexPreparationLocation, inspectCodexPrepared, prepareCodexCandidate, readCodexPrepared, validateCodexPreparationBeforeFetch, } from "./prepare.js";
5
+ import { codexInstallReceipt, codexPresentation } from "./presentation.js";
5
6
  import { reportLegacyState, requireManagedUpdateControl, requireNoLegacyState, verifyUninstalledResources, } from "./lifecycle.js";
6
- import { commitMatches } from "./status.js";
7
+ import { SafetyError } from "../../safety-error.js";
8
+ import { commitMatches } from "../../status.js";
7
9
  function preserveFailure(result) {
8
10
  if (result.outcome.ok) {
9
11
  throw new Error("cannot preserve a successful adapter result as failure");
@@ -40,6 +42,23 @@ function resourceFlag(result, key) {
40
42
  const value = bag[key];
41
43
  return typeof value === "boolean" ? value : null;
42
44
  }
45
+ function conflictDescriptions(result) {
46
+ const raw = result.conflicts;
47
+ if (raw === null || raw === undefined)
48
+ return [];
49
+ if (!Array.isArray(raw))
50
+ return null;
51
+ const descriptions = [];
52
+ for (const item of raw) {
53
+ if (typeof item !== "string" ||
54
+ item.length === 0 ||
55
+ hasTerminalControl(item)) {
56
+ return null;
57
+ }
58
+ descriptions.push(item);
59
+ }
60
+ return descriptions;
61
+ }
43
62
  function installDecision(legacy) {
44
63
  switch (legacy.kind) {
45
64
  case "ok":
@@ -87,7 +106,11 @@ export function normalizeCodexOwnership(result) {
87
106
  return malformed(result, "adapter returned a non-string identity_state for inspect --view ownership");
88
107
  }
89
108
  const identityState = identity.value;
90
- const installEligibility = identityState.length === 0
109
+ const conflicts = conflictDescriptions(record);
110
+ if (conflicts === null) {
111
+ return malformed(result, "expected an array of strings at conflicts");
112
+ }
113
+ const legacyEligibility = identityState.length === 0
91
114
  ? {
92
115
  kind: "blocked",
93
116
  output: {
@@ -96,6 +119,19 @@ export function normalizeCodexOwnership(result) {
96
119
  },
97
120
  }
98
121
  : installDecision(requireNoLegacyState(identityState));
122
+ const installEligibility = legacyEligibility.kind === "blocked" || conflicts.length === 0
123
+ ? legacyEligibility
124
+ : {
125
+ kind: "blocked",
126
+ output: {
127
+ stdout: [],
128
+ stderr: [
129
+ "Conflicting unmanaged Superpowers Codex resources require manual resolution:",
130
+ ...conflicts.map((conflict) => `- ${conflict}`),
131
+ "Remove or disable each resource manually, then retry.",
132
+ ],
133
+ },
134
+ };
99
135
  const verification = verifyUninstalledResources(result);
100
136
  const legacyReport = reportLegacyState(identityState);
101
137
  let removalVerification;
@@ -132,6 +168,7 @@ export function normalizeCodexOwnership(result) {
132
168
  removalVerification,
133
169
  postRemovalOutput,
134
170
  presentationValue: identityState,
171
+ presentationConflicts: conflicts,
135
172
  }, result.outcome.messages);
136
173
  }
137
174
  export function normalizeCodexControl(result) {
@@ -243,8 +280,21 @@ function requirements(command, env) {
243
280
  },
244
281
  ];
245
282
  }
283
+ async function mutationRoots(ctx) {
284
+ const env = ctx.env ?? {};
285
+ const configured = env.CODEX_HOME;
286
+ if (configured !== undefined && configured.length > 0) {
287
+ return [resolve(configured)];
288
+ }
289
+ const home = env.HOME;
290
+ if (home === undefined || home.length === 0) {
291
+ throw new SafetyError("codex-harness", "cannot determine Codex state root without HOME");
292
+ }
293
+ return [join(resolve(home), ".codex")];
294
+ }
246
295
  export const codexHarness = {
247
296
  preparationLocation: codexPreparationLocation,
297
+ mutationRoots,
248
298
  validatePreparationBeforeFetch: validateCodexPreparationBeforeFetch,
249
299
  prepareCandidate: prepareCodexCandidate,
250
300
  inspectPrepared: inspectCodexPrepared,
@@ -1,8 +1,8 @@
1
1
  import { copyFile, cp, mkdir, readdir, readFile, readlink, stat, symlink, } from "node:fs/promises";
2
2
  import { dirname, isAbsolute, join, resolve } from "node:path";
3
- import { assertExistingContained, assertProspectiveContained, classifyPathNoFollow, } from "./safe-path.js";
4
- import { SafetyError } from "./safety-error.js";
5
- import { parseStrictJson } from "./strict-json.js";
3
+ import { assertExistingContained, assertProspectiveContained, classifyPathNoFollow, } from "../../safe-path.js";
4
+ import { SafetyError } from "../../safety-error.js";
5
+ import { parseStrictJson } from "../../strict-json.js";
6
6
  // Annotated, not `as const`, matching
7
7
  // `src/provenance.ts:20::export const PROVENANCE_STRICT_PROFILE` and
8
8
  // `src/selection-store.ts:19::const SELECTION_JSON_PROFILE`. The annotation
@@ -77,11 +77,11 @@ export async function readManifest(path) {
77
77
  catch {
78
78
  // Deliberately drops the cause on both branches. `detail(cause)` on the
79
79
  // read branch surfaced a raw errno (`ENOENT: … open '<path>'`), which the
80
- // `hook classification failed:` site in src/adapter.ts re-emits onto the
80
+ // `hook classification failed:` site in src/harnesses/codex/adapter.ts re-emits onto the
81
81
  // terminal these commands write to; the parse branch surfaced
82
- // strict-json's own wording under a prefix src/manifest-overlay.ts also
82
+ // strict-json's own wording under a prefix src/harnesses/codex/manifest-overlay.ts also
83
83
  // uses with CPython wording.
84
- // Same text as the `cannot read manifest JSON in` site in src/adapter.ts.
84
+ // Same text as the `cannot read manifest JSON in` site in src/harnesses/codex/adapter.ts.
85
85
  throw hookError(`cannot read manifest JSON in ${path}`);
86
86
  }
87
87
  let parsed;
@@ -1,5 +1,5 @@
1
- import { SafetyError } from "./safety-error.js";
2
- import { parseStrictJson, } from "./strict-json.js";
1
+ import { SafetyError } from "../../safety-error.js";
2
+ import { parseStrictJson, } from "../../strict-json.js";
3
3
  const ACCEPT_CONSTANTS = {
4
4
  duplicateKeys: "last-wins",
5
5
  nonStandardConstants: "accept",
@@ -59,6 +59,13 @@ function hasTerminalControl(value) {
59
59
  export function installedListingHas(raw, arrayKey, field, value) {
60
60
  return checkedItems(raw, ACCEPT_CONSTANTS, arrayKey, field).some((item) => item[field] === value);
61
61
  }
62
+ export function codexInstalledPluginsFromJson(raw) {
63
+ return checkedItems(raw, ACCEPT_CONSTANTS, "installed", "pluginId").map((item) => ({
64
+ pluginId: item.pluginId,
65
+ installed: typeof item.installed === "boolean" ? item.installed : null,
66
+ enabled: typeof item.enabled === "boolean" ? item.enabled : null,
67
+ }));
68
+ }
62
69
  export function marketplaceRootFromJson(raw, marketplaceName) {
63
70
  const items = checkedItems(raw, ACCEPT_CONSTANTS, "marketplaces", "name");
64
71
  const match = items.find((item) => item.name === marketplaceName);
@@ -52,7 +52,7 @@ export function reportLegacyState(identityState) {
52
52
  }
53
53
  return unknownState(identityState);
54
54
  }
55
- export { verifyInstalledFingerprint, } from "./codex-presentation.js";
55
+ export { verifyInstalledFingerprint, } from "./presentation.js";
56
56
  // Ported from
57
57
  // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/lifecycle.sh:62-70::spw_require_managed_update_control`.
58
58
  // Both refusals reached spw_die in the shell, so both carry the `error: ` prefix
@@ -101,7 +101,7 @@ export function verifyUninstalledResources(inspectResult) {
101
101
  // rather than getting its own message.
102
102
  // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/adapter.sh:70::expected`
103
103
  // emits "expected Boolean adapter result at resources.plugin" for input {} —
104
- // the input tests/unit/lifecycle.test.js's "a non-object resources falls
104
+ // the input tests/unit/harnesses/codex/lifecycle.test.ts's "a non-object resources falls
105
105
  // through to the Boolean message" exercises — so a distinct "not an object"
106
106
  // message here would be a port-only divergence. Parity, not divergence. Spec
107
107
  // §6.2.3 item 3.
@@ -1,6 +1,6 @@
1
- import { escapeNonAscii, formatPythonNumber } from "./python-json-format.js";
2
- import { SafetyError } from "./safety-error.js";
3
- import { isRawNumber, isRawObject, parseStrictJsonPreservingNumbers, } from "./strict-json.js";
1
+ import { escapeNonAscii, formatPythonNumber, } from "../../python-json-format.js";
2
+ import { SafetyError } from "../../safety-error.js";
3
+ import { isRawNumber, isRawObject, parseStrictJsonPreservingNumbers, } from "../../strict-json.js";
4
4
  const OVERLAY_PROFILE = {
5
5
  duplicateKeys: "last-wins",
6
6
  nonStandardConstants: "reject",
@@ -12,7 +12,7 @@ const OVERLAY_PROFILE = {
12
12
  * Byte-for-byte equivalent to the CPython implementation this replaces:
13
13
  * `json.dump(data, f, indent=2, allow_nan=False)` plus a trailing newline.
14
14
  * The byte-parity evidence lives in
15
- * `tests/baseline/manifest-overlay-parity.test.js` (tracked as
15
+ * `tests/baseline/harnesses/codex/manifest-overlay-parity.test.ts` (tracked as
16
16
  * `MANIFEST-READER-OVERLAY-01` in `docs/baseline/traceability.md`) against
17
17
  * the fixtures under `tests/fixtures/baseline/overlay-parity/`. Known,
18
18
  * deliberate divergences from strict byte parity are recorded in
@@ -69,7 +69,7 @@ function setMember(entries, key, value) {
69
69
  }
70
70
  /**
71
71
  * Map strict-json's diagnostics onto the CPython wording the callers assert.
72
- * Repository policy at `AGENTS.md:91::most diagnostics are asserted as`
72
+ * Repository policy at `AGENTS.md:92::most diagnostics are asserted as`
73
73
  * freezes most of these diagnostics as complete-string contracts.
74
74
  */
75
75
  function translate(cause, source, path) {
@@ -1,12 +1,14 @@
1
1
  import { cp, mkdir, rm, stat } from "node:fs/promises";
2
2
  import { isAbsolute, join, resolve } from "node:path";
3
- import { failureResult, successResult, } from "./adapter-result.js";
3
+ import { failureResult, successResult, } from "../../adapter-result.js";
4
4
  import { codexBuild } from "./adapter.js";
5
- import { commitMatches } from "./domain/fingerprint.js";
5
+ import { ARTIFACT_RECEIPT } from "../../artifact-tree.js";
6
+ import { assessCodexCompatibility, readCodexAssessment, writeCodexAssessment, } from "./compatibility.js";
6
7
  import { readManifest } from "./hooks.js";
7
- import { generatedCommitOrEmpty, generatedMetadataPath, readStrictProvenanceField, writeProvenance, } from "./provenance.js";
8
- import { SafetyError } from "./safety-error.js";
9
- import { manifestVersionForRef } from "./upstream-version.js";
8
+ import { readGeneratedCommitLenient, readStrictProvenanceField, writeProvenance, } from "../../provenance.js";
9
+ import { classifyPathNoFollow } from "../../safe-path.js";
10
+ import { SafetyError } from "../../safety-error.js";
11
+ import { manifestVersionForRef } from "../../upstream-version.js";
10
12
  // Order is inherited from the original prepare command; the first miss wins.
11
13
  const REQUIRED_UPSTREAM = [
12
14
  { path: "skills", label: "skills/" },
@@ -27,6 +29,12 @@ const RESOLUTION_KINDS = [
27
29
  "ref",
28
30
  "raw-commit",
29
31
  ];
32
+ function unassessedCompatibility() {
33
+ return {
34
+ kind: "unknown",
35
+ reason: "Codex compatibility assessment is not available",
36
+ };
37
+ }
30
38
  function prepareError(message, cause) {
31
39
  return new SafetyError("prepare", message, { cause });
32
40
  }
@@ -166,26 +174,66 @@ export async function prepareCodexCandidate(input, ctx) {
166
174
  if (built.status !== 0) {
167
175
  return failureResult("build", "invalid-status", "adapter reported failure without an error outcome", [], built.outcome.messages);
168
176
  }
169
- return successResult(built.outcome.operation, { root: input.candidateRoot, commit: input.selection.desiredCommit }, built.outcome.messages);
177
+ const compatibility = await assessCodexCompatibility(input.upstreamRoot, input.selection);
178
+ if (compatibility.kind === "supported") {
179
+ try {
180
+ await writeCodexAssessment(input.candidateRoot, input.selection, (await regularFileExists(join(input.upstreamRoot, ".codex-plugin/plugin.json")))
181
+ ? "upstream"
182
+ : "fallback");
183
+ }
184
+ catch {
185
+ return failureResult("prepare", "assessment-failed", `cannot record Codex compatibility assessment: ${input.candidateRoot}`, [], built.outcome.messages);
186
+ }
187
+ }
188
+ return successResult(built.outcome.operation, {
189
+ root: input.candidateRoot,
190
+ commit: input.selection.desiredCommit,
191
+ compatibility,
192
+ identity: input.selection.desiredCommit,
193
+ }, built.outcome.messages);
170
194
  }
171
195
  export async function inspectCodexPrepared(selection, ctx) {
172
- const observedIdentity = await generatedCommitOrEmpty(ctx.root);
173
- if (!commitMatches(selection.desiredCommit, observedIdentity)) {
174
- return successResult("inspect-prepared", { kind: "needs-prepare", observedIdentity }, []);
196
+ const root = codexPreparationLocation(ctx).destinationRoot;
197
+ const observedIdentity = await readGeneratedCommitLenient(join(root, ".superpowers-upstream.json"));
198
+ const compatibility = unassessedCompatibility();
199
+ try {
200
+ if ((await classifyPathNoFollow(join(root, ARTIFACT_RECEIPT))) === "missing")
201
+ return successResult("inspect-prepared", {
202
+ kind: "needs-prepare",
203
+ observedIdentity,
204
+ compatibility,
205
+ }, []);
206
+ const artifact = await readCodexAssessment(root);
207
+ const source = await readStrictProvenanceField(join(artifact.root, ".superpowers-upstream.json"), "source");
208
+ if (artifact.commit === selection.desiredCommit &&
209
+ source === selection.effectiveSource) {
210
+ if (artifact.compatibility.kind === "supported")
211
+ return successResult("inspect-prepared", {
212
+ kind: "current",
213
+ artifact,
214
+ observedIdentity,
215
+ compatibility: artifact.compatibility,
216
+ }, []);
217
+ return successResult("inspect-prepared", {
218
+ kind: "needs-prepare",
219
+ observedIdentity,
220
+ compatibility: artifact.compatibility,
221
+ }, []);
222
+ }
223
+ }
224
+ catch {
225
+ return failureResult("inspect-prepared", "invalid-assessment", `cannot inspect Codex prepared artifact: ${root}`, [], []);
175
226
  }
176
227
  return successResult("inspect-prepared", {
177
- kind: "current",
178
- artifact: {
179
- root: join(ctx.root, "plugins", "superpowers"),
180
- commit: observedIdentity,
181
- },
228
+ kind: "needs-prepare",
182
229
  observedIdentity,
230
+ compatibility,
183
231
  }, []);
184
232
  }
185
233
  export async function readCodexPrepared(ctx) {
186
234
  let commit = "";
187
235
  try {
188
- const value = await readStrictProvenanceField(generatedMetadataPath(ctx.root), "commit");
236
+ const value = await readStrictProvenanceField(join(codexPreparationLocation(ctx).destinationRoot, ".superpowers-upstream.json"), "commit");
189
237
  if (typeof value === "string")
190
238
  commit = value;
191
239
  }
@@ -195,5 +243,13 @@ export async function readCodexPrepared(ctx) {
195
243
  if (commit.length === 0) {
196
244
  return failureResult("read-prepared", "missing-commit", "generated metadata missing desired commit after prepare", [], []);
197
245
  }
198
- return successResult("read-prepared", { root: join(ctx.root, "plugins", "superpowers"), commit }, []);
246
+ try {
247
+ const artifact = await readCodexAssessment(codexPreparationLocation(ctx).destinationRoot);
248
+ if (artifact.compatibility.kind !== "supported")
249
+ throw new Error("unsupported profile");
250
+ return successResult("read-prepared", artifact, []);
251
+ }
252
+ catch {
253
+ return failureResult("read-prepared", "invalid-assessment", "generated Codex compatibility assessment is missing or invalid", [], []);
254
+ }
199
255
  }