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,71 @@
1
+ import { computeEffectiveSelection } from "../effective-selection.js";
2
+ import { upstreamCacheRoot } from "../upstream-workspace.js";
3
+ import { SafetyError } from "../safety-error.js";
4
+ import { isAbsolute } from "node:path";
5
+ import { oneLine } from "../cli-arguments.js";
6
+ export async function withMutation(command, ctx, action) {
7
+ // Selection validation precedes every adapter call and lock creation. A
8
+ // nested mutation receives the already resolved value and cannot observe a
9
+ // saved-selection change midway through one invocation.
10
+ const selection = command === "uninstall"
11
+ ? ctx.selection
12
+ : (ctx.selection ?? (await computeEffectiveSelection(ctx.root, ctx.env)));
13
+ const scoped = selection === undefined ? ctx : { ...ctx, selection };
14
+ const adapterContext = { root: ctx.root, env: ctx.env };
15
+ let location;
16
+ try {
17
+ location = ctx.adapter.preparationLocation(adapterContext);
18
+ }
19
+ catch (cause) {
20
+ throw new SafetyError("mutation", "cannot determine preparation location", {
21
+ cause,
22
+ });
23
+ }
24
+ if (!isAbsolute(location.destinationRoot))
25
+ throw new SafetyError("mutation", "adapter returned a non-absolute preparation destination");
26
+ if (!location.stagingLeaf ||
27
+ location.stagingLeaf === "." ||
28
+ location.stagingLeaf === ".." ||
29
+ location.stagingLeaf.includes("/") ||
30
+ location.stagingLeaf.includes("\\"))
31
+ throw new SafetyError("mutation", "adapter returned an invalid preparation staging leaf");
32
+ let mutationRoots;
33
+ try {
34
+ mutationRoots = await ctx.adapter.mutationRoots(adapterContext);
35
+ if (!Array.isArray(mutationRoots) ||
36
+ mutationRoots.some((root) => typeof root !== "string" || !isAbsolute(root)))
37
+ throw new Error("invalid roots");
38
+ }
39
+ catch (cause) {
40
+ throw new SafetyError("mutation", "cannot determine harness mutation resources", { cause });
41
+ }
42
+ const resources = [location.destinationRoot, ...mutationRoots];
43
+ if (command === "prepare") {
44
+ resources.push(upstreamCacheRoot(ctx.root, ctx.env, process.cwd()));
45
+ }
46
+ // The coordinator sorts each acquisition and refuses conflicts immediately.
47
+ // A nested prepare may add a cache lock that sorts before an outer target;
48
+ // it cannot deadlock because no acquisition waits, while already-held roots
49
+ // are reentrant for this invocation.
50
+ return await ctx.coordination.withResources(resources, async () => action(scoped));
51
+ }
52
+ export async function runWithMutation(command, ctx, action) {
53
+ let actionThrew = false;
54
+ try {
55
+ return await withMutation(command, ctx, async (scoped) => {
56
+ try {
57
+ return await action(scoped);
58
+ }
59
+ catch (cause) {
60
+ actionThrew = true;
61
+ throw cause;
62
+ }
63
+ });
64
+ }
65
+ catch (cause) {
66
+ if (actionThrew)
67
+ throw cause;
68
+ ctx.stderr.write(`error: ${oneLine(cause)}\n`);
69
+ return 1;
70
+ }
71
+ }
@@ -1,18 +1,20 @@
1
1
  import { mkdir, stat } from "node:fs/promises";
2
- import { dirname, isAbsolute, join, resolve, sep } from "node:path";
2
+ import { dirname, isAbsolute, join, sep } from "node:path";
3
3
  import { atomicReplaceDir } from "../atomic.js";
4
4
  import { oneLine } from "../cli-arguments.js";
5
5
  import { computeEffectiveSelection } from "../effective-selection.js";
6
6
  import { runGit } from "../git.js";
7
7
  import { SafetyError } from "../safety-error.js";
8
8
  import { fetchExactCommit, gitSafeSource } from "../upstream.js";
9
+ import { upstreamCacheRoot } from "../upstream-workspace.js";
9
10
  import { BOUNDED_EXECUTABLE, UNBOUNDED_LEGACY, launchFailureMessage, resolveValidator, runValidator, displayPath, } from "../validator.js";
10
11
  import { withWorkspace, workspaceRemovalFailure } from "../workspace.js";
11
12
  import { replayOutcome } from "./probe.js";
13
+ import { runWithMutation } from "./mutation.js";
12
14
  // Every message this module writes is hand-written here. The cause is attached
13
15
  // for debuggability and never reaches a stream: oneLine (src/cli-arguments.ts)
14
16
  // reads .message only. Same arrangement as hookError
15
- // (`src/hooks.ts:44::function hookError`).
17
+ // (`src/harnesses/codex/hooks.ts:44::function hookError`).
16
18
  function prepareError(message, cause) {
17
19
  return new SafetyError("prepare", message, { cause });
18
20
  }
@@ -93,10 +95,8 @@ async function gatherPrepare(ctx) {
93
95
  // getcwd(3) returns the physical path, so this matches `pwd -P` without a
94
96
  // realpath call.
95
97
  const cwd = process.cwd();
96
- const configuredCache = env.SUPERPOWERS_CACHE_DIR || join(ctx.root, ".cache", "upstream");
97
- const cacheParent = isAbsolute(configuredCache)
98
- ? configuredCache
99
- : resolve(cwd, configuredCache);
98
+ const cache = upstreamCacheRoot(ctx.root, env, cwd);
99
+ const cacheParent = dirname(cache);
100
100
  const adapterContext = { root: ctx.root, env };
101
101
  let location;
102
102
  try {
@@ -114,7 +114,6 @@ async function gatherPrepare(ctx) {
114
114
  const pluginRoot = location.destinationRoot;
115
115
  const additionalValidator = env.SUPERPOWERS_VALIDATOR || "";
116
116
  const executableValidator = env.SUPERPOWERS_VALIDATOR_EXECUTABLE || "";
117
- const cache = join(cacheParent, "superpowers");
118
117
  const tmpParent = dirname(pluginRoot);
119
118
  await owned(`cannot create directory: ${tmpParent}`, () => mkdir(tmpParent, { recursive: true }));
120
119
  let cleanupWarning = null;
@@ -127,7 +126,7 @@ async function gatherPrepare(ctx) {
127
126
  message,
128
127
  });
129
128
  const candidate = join(workspace, location.stagingLeaf);
130
- const selection = await computeEffectiveSelection(ctx.root, env);
129
+ const selection = ctx.selection ?? (await computeEffectiveSelection(ctx.root, env));
131
130
  let prefetch;
132
131
  try {
133
132
  prefetch =
@@ -211,6 +210,9 @@ async function gatherPrepare(ctx) {
211
210
  if (prepared.outcome.result.commit !== selection.desiredCommit) {
212
211
  return failed("adapter returned an unexpected preparation commit");
213
212
  }
213
+ if (prepared.outcome.result.compatibility.kind === "unsupported") {
214
+ return failed(prepared.outcome.result.compatibility.reason);
215
+ }
214
216
  let validator = NO_VALIDATOR_OUTPUT;
215
217
  if (additionalValidator.length > 0) {
216
218
  // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/prepare:108::[ -f "$additional_validator` — `[ -f ]`.
@@ -284,11 +286,12 @@ async function gatherPrepare(ctx) {
284
286
  // The swap must run inside the workspace callback: withWorkspace removes
285
287
  // the workspace on return, and the candidate lives in it.
286
288
  //
287
- // atomicReplaceDir's outer catch (`src/atomic.ts:208-215::if (cause`) wraps every
288
- // non-SafetyError into a SafetyError, so the callee owns every failure on
289
- // this path and re-emitting its own diagnostic is the sanctioned form of
290
- // interpolation. The hand-written prefix carries the live root, which the
291
- // callee's message does not.
289
+ // atomicReplaceDir delegates to beginDirectoryPublication, whose outer
290
+ // catch (`src/atomic.ts:341-348::if (cause`) wraps every non-SafetyError
291
+ // into a SafetyError, so the callee owns every failure on this path and
292
+ // re-emitting its own diagnostic is the sanctioned form of interpolation.
293
+ // The hand-written prefix carries the live root, which the callee's message
294
+ // does not.
292
295
  try {
293
296
  await atomicReplaceDir(candidate, pluginRoot);
294
297
  }
@@ -319,6 +322,9 @@ async function gatherPrepare(ctx) {
319
322
  return { outcome, cleanupWarning };
320
323
  }
321
324
  export async function runPrepare(argv, ctx) {
325
+ return await runWithMutation("prepare", ctx, async (scoped) => performPrepare(argv, scoped));
326
+ }
327
+ async function performPrepare(argv, ctx) {
322
328
  // scripts/prepare never reads "$@", so extra arguments are ignored. This is a
323
329
  // deliberate asymmetry with probe, whose shell original rejected unknown
324
330
  // arguments and whose arity therefore moved into parseArgs in slice 2.
@@ -335,8 +341,8 @@ export async function runPrepare(argv, ctx) {
335
341
  // failure (its cause is the shared runner's (src/validator.ts) captured
336
342
  // spawn error, which oneLine never reads -- it takes .message only);
337
343
  // readManifest's three hookError messages
338
- // (`src/hooks.ts:113-138::readManifest`), pinned by
339
- // `tests/unit/hooks.test.ts:95::void test("readManifest diagnostics` as carrying no reader vocabulary or
344
+ // (`src/harnesses/codex/hooks.ts:113-138::readManifest`), pinned by
345
+ // `tests/unit/harnesses/codex/hooks.test.ts:95::void test("readManifest diagnostics` as carrying no reader vocabulary or
340
346
  // errno; and SafetyErrors from gitSafeSource, writeProvenance, and
341
347
  // withWorkspace.
342
348
  //
@@ -376,7 +382,7 @@ export async function runPrepare(argv, ctx) {
376
382
  // only, so it bounds how much of that text lands, not what it may
377
383
  // contain.
378
384
  //
379
- // runAdapter's closing `throw cause` (src/adapter.ts) does NOT arrive
385
+ // runAdapter's closing `throw cause` (src/harnesses/codex/adapter.ts) does NOT arrive
380
386
  // here -- the call site catches it and converts it to a hand-written
381
387
  // message per AGENTS.md's reader-diagnostics rule.
382
388
  //
@@ -412,7 +418,7 @@ export async function runPrepare(argv, ctx) {
412
418
  // completed before cleanup ran, so it is not being reported as unverified
413
419
  // -- but something did still go wrong, and AGENTS.md's fail-closed rule
414
420
  // extends to it. Mirrors
415
- // `src/commands/install.ts:395-403::if (cleanupWarning`.
421
+ // `src/commands/install.ts:546-554::if (cleanupWarning`.
416
422
  ctx.stderr.write(`error: ${cleanupWarning}\n`);
417
423
  return 1;
418
424
  }
@@ -11,7 +11,7 @@ export const PROBE_USAGE = "error: usage: superpowers-manager probe [--porcelain
11
11
  // silent diagnostics regression, not a simplification.
12
12
  //
13
13
  // Interpolating error.message and each hint is the sanctioned form (AGENTS.md):
14
- // src/adapter.ts has 52 `fail()` sites and none interpolates a caught error's
14
+ // src/harnesses/codex/adapter.ts has 52 `fail()` sites and none interpolates a caught error's
15
15
  // message, so the callee owns every failure reachable on this path. Those two
16
16
  // writes now live in writeAdapterFailure (src/adapter-result.ts), which
17
17
  // validates all three strings before the first of them reaches the stream.
@@ -33,6 +33,86 @@ export function replayOutcome(outcome, ctx) {
33
33
  // stored, so scanning it here would re-check a population that cannot fail.
34
34
  writeAdapterFailure(ctx, outcome);
35
35
  }
36
+ function record(value) {
37
+ return value !== null && typeof value === "object"
38
+ ? value
39
+ : null;
40
+ }
41
+ export function validOutput(value) {
42
+ const candidate = record(value);
43
+ return (candidate !== null &&
44
+ Array.isArray(candidate.stdout) &&
45
+ candidate.stdout.every((line) => typeof line === "string") &&
46
+ Array.isArray(candidate.stderr) &&
47
+ candidate.stderr.every((line) => typeof line === "string"));
48
+ }
49
+ function validDecision(value) {
50
+ const candidate = record(value);
51
+ return (candidate !== null &&
52
+ (candidate.kind === "allowed" ||
53
+ (candidate.kind === "blocked" && validOutput(candidate.output))));
54
+ }
55
+ function validCompatibility(value) {
56
+ const candidate = record(value);
57
+ if (candidate === null ||
58
+ typeof candidate.reason !== "string" ||
59
+ (candidate.kind !== "unknown" &&
60
+ candidate.kind !== "supported" &&
61
+ candidate.kind !== "experimental" &&
62
+ candidate.kind !== "unsupported"))
63
+ return false;
64
+ return ((candidate.kind !== "supported" && candidate.kind !== "experimental") ||
65
+ typeof candidate.generation === "string");
66
+ }
67
+ function validPreparedArtifact(value) {
68
+ const candidate = record(value);
69
+ return (candidate !== null &&
70
+ typeof candidate.root === "string" &&
71
+ typeof candidate.commit === "string" &&
72
+ typeof candidate.identity === "string" &&
73
+ validCompatibility(candidate.compatibility));
74
+ }
75
+ function validPrepared(value) {
76
+ const candidate = record(value);
77
+ return (candidate !== null &&
78
+ typeof candidate.observedIdentity === "string" &&
79
+ validCompatibility(candidate.compatibility) &&
80
+ (candidate.kind === "needs-prepare" ||
81
+ (candidate.kind === "current" &&
82
+ validPreparedArtifact(candidate.artifact))));
83
+ }
84
+ export function validInstalled(value) {
85
+ const candidate = record(value);
86
+ return (candidate !== null &&
87
+ (candidate.kind === "current" ||
88
+ candidate.kind === "mismatch" ||
89
+ candidate.kind === "absent") &&
90
+ typeof candidate.observedIdentity === "string" &&
91
+ (candidate.kind !== "absent" || candidate.observedIdentity === ""));
92
+ }
93
+ function validOwnership(value) {
94
+ const candidate = record(value);
95
+ return (candidate !== null &&
96
+ validDecision(candidate.installEligibility) &&
97
+ Object.prototype.hasOwnProperty.call(candidate, "removalInput") &&
98
+ validDecision(candidate.removalVerification) &&
99
+ validOutput(candidate.postRemovalOutput) &&
100
+ typeof candidate.presentationValue === "string" &&
101
+ (candidate.presentationConflicts === undefined ||
102
+ (Array.isArray(candidate.presentationConflicts) &&
103
+ candidate.presentationConflicts.every((conflict) => typeof conflict === "string"))));
104
+ }
105
+ function validControl(value) {
106
+ const candidate = record(value);
107
+ return (candidate !== null &&
108
+ validDecision(candidate.probeEligibility) &&
109
+ validDecision(candidate.mutationEligibility) &&
110
+ typeof candidate.presentationValue === "string" &&
111
+ (candidate.recoveryState === undefined ||
112
+ (candidate.recoveryState === "required" &&
113
+ candidate.probeEligibility.kind === "blocked" &&
114
+ candidate.mutationEligibility.kind === "blocked")));
115
+ }
36
116
  // `runAdapter` reports a CONTROLLED failure by RETURN VALUE, not by throwing
37
117
  // (`src/adapter-result.ts:32-35::export interface AdapterResult`). The shell got
38
118
  // fail-closed behaviour for free: spw_invoke_adapter returned 1 and
@@ -41,17 +121,23 @@ export function replayOutcome(outcome, ctx) {
41
121
  // evidence and report it as success.
42
122
  //
43
123
  // It does still THROW for a non-AdapterFailure cause (runAdapter's closing
44
- // `throw cause`, src/adapter.ts). That is caught here rather than in runProbe's
124
+ // `throw cause`, src/harnesses/codex/adapter.ts). That is caught here rather than in runProbe's
45
125
  // outer catch, because the two need different diagnostics -- see spec §3.3a.
46
126
  //
47
127
  // Reached through ctx.adapter, not a direct module-level dependency on the
48
128
  // adapter module: src/commands/prepare.ts and this module are the two the
49
129
  // injected double must observe, because install reaches the adapter through
50
130
  // gatherProbe and runPrepare. Spec §4.5.
51
- async function inspect(call, unexpected, invalidStatus) {
131
+ async function inspect(call, unexpected, invalidStatus, valid) {
52
132
  let result;
53
133
  try {
54
134
  result = await call();
135
+ if (!result ||
136
+ !Number.isInteger(result.status) ||
137
+ !result.outcome ||
138
+ typeof result.outcome.ok !== "boolean" ||
139
+ !Array.isArray(result.outcome.messages))
140
+ return { ok: false, result: null, message: invalidStatus };
55
141
  }
56
142
  catch {
57
143
  return { ok: false, result: null, message: unexpected };
@@ -67,6 +153,14 @@ async function inspect(call, unexpected, invalidStatus) {
67
153
  message: outcome.ok ? invalidStatus : null,
68
154
  };
69
155
  }
156
+ try {
157
+ if (!valid(outcome.result)) {
158
+ return { ok: false, result, message: invalidStatus };
159
+ }
160
+ }
161
+ catch {
162
+ return { ok: false, result, message: invalidStatus };
163
+ }
70
164
  return { ok: true, result: { status: result.status, outcome } };
71
165
  }
72
166
  // Runs every step that can throw or fail closed, returning the outcome as
@@ -86,35 +180,84 @@ async function inspect(call, unexpected, invalidStatus) {
86
180
  // gone.
87
181
  export async function gatherProbe(ctx) {
88
182
  // Order mirrors `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/probe:24-40::spw_compute_effective_selection` exactly.
89
- const selection = await computeEffectiveSelection(ctx.root, ctx.env);
183
+ const selection = ctx.selection ?? (await computeEffectiveSelection(ctx.root, ctx.env));
90
184
  const outcomes = [];
91
185
  const adapterContext = { root: ctx.root, env: ctx.env };
92
- const collect = async (call, unexpected, invalidStatus) => {
93
- const result = await inspect(call, unexpected, invalidStatus);
94
- if (result.result !== null)
186
+ let resources;
187
+ try {
188
+ resources = [
189
+ ctx.adapter.preparationLocation(adapterContext).destinationRoot,
190
+ ...(await ctx.adapter.mutationRoots(adapterContext)),
191
+ ];
192
+ }
193
+ catch {
194
+ resources = null;
195
+ }
196
+ const before = resources === null
197
+ ? []
198
+ : await ctx.coordination.observeResources(resources);
199
+ const collect = async (call, unexpected, invalidStatus, valid, replaySuccess = true) => {
200
+ const result = await inspect(call, unexpected, invalidStatus, valid);
201
+ if (result.result !== null && (replaySuccess || !result.result.outcome.ok))
95
202
  outcomes.push(result.result.outcome);
96
203
  return result;
97
204
  };
98
- const prepared = await collect(() => ctx.adapter.inspectPrepared(selection, adapterContext), "cannot inspect prepared harness state", "adapter reported a failure status for prepared harness inspection");
205
+ const prepared = await collect(() => ctx.adapter.inspectPrepared(selection, adapterContext), "cannot inspect prepared harness state", "adapter reported a failure status for prepared harness inspection", validPrepared);
99
206
  if (!prepared.ok) {
100
207
  return { status: 1, outcomes, message: prepared.message };
101
208
  }
102
209
  const failure = (site) => ctx.adapter.presentation.callFailure(site, adapterContext);
103
210
  const installedFailure = failure("probe-installed");
104
- const installed = await collect(() => ctx.adapter.inspectInstalled(selection, adapterContext), installedFailure.unexpected, installedFailure.invalidStatus);
211
+ const installed = await collect(() => ctx.adapter.inspectInstalled(selection, adapterContext), installedFailure.unexpected, installedFailure.invalidStatus, validInstalled);
105
212
  if (!installed.ok) {
106
213
  return { status: 1, outcomes, message: installed.message };
107
214
  }
215
+ if (resources === null)
216
+ return {
217
+ status: 1,
218
+ outcomes,
219
+ message: "cannot determine harness observation resources",
220
+ };
108
221
  const ownershipFailure = failure("probe-ownership");
109
- const ownership = await collect(() => ctx.adapter.inspectOwnership(adapterContext), ownershipFailure.unexpected, ownershipFailure.invalidStatus);
222
+ const ownership = await collect(() => ctx.adapter.inspectOwnership(adapterContext), ownershipFailure.unexpected, ownershipFailure.invalidStatus, validOwnership);
110
223
  if (!ownership.ok) {
111
224
  return { status: 1, outcomes, message: ownership.message };
112
225
  }
113
226
  const controlFailure = failure("probe-control");
114
- const control = await collect(() => ctx.adapter.inspectUpdateControl(adapterContext), controlFailure.unexpected, controlFailure.invalidStatus);
227
+ const control = await collect(() => ctx.adapter.inspectUpdateControl(adapterContext), controlFailure.unexpected, controlFailure.invalidStatus, validControl);
115
228
  if (!control.ok) {
116
229
  return { status: 1, outcomes, message: control.message };
117
230
  }
231
+ const preparedAfter = await collect(() => ctx.adapter.inspectPrepared(selection, adapterContext), "cannot inspect prepared harness state", "adapter reported a failure status for prepared harness inspection", validPrepared, false);
232
+ if (!preparedAfter.ok)
233
+ return { status: 1, outcomes, message: preparedAfter.message };
234
+ const installedAfter = await collect(() => ctx.adapter.inspectInstalled(selection, adapterContext), installedFailure.unexpected, installedFailure.invalidStatus, validInstalled, false);
235
+ if (!installedAfter.ok)
236
+ return { status: 1, outcomes, message: installedAfter.message };
237
+ const controlAfter = await collect(() => ctx.adapter.inspectUpdateControl(adapterContext), controlFailure.unexpected, controlFailure.invalidStatus, validControl, false);
238
+ if (!controlAfter.ok)
239
+ return { status: 1, outcomes, message: controlAfter.message };
240
+ const after = await ctx.coordination.observeResources(resources);
241
+ if (JSON.stringify(before) !== JSON.stringify(after) ||
242
+ JSON.stringify(prepared.result.outcome.result) !==
243
+ JSON.stringify(preparedAfter.result.outcome.result) ||
244
+ JSON.stringify(installed.result.outcome.result) !==
245
+ JSON.stringify(installedAfter.result.outcome.result) ||
246
+ JSON.stringify(control.result.outcome.result) !==
247
+ JSON.stringify(controlAfter.result.outcome.result)) {
248
+ return {
249
+ status: 1,
250
+ outcomes,
251
+ message: "harness state changed during observation; retry for a coherent probe",
252
+ };
253
+ }
254
+ const unavailable = after.find((observation) => observation.state === "busy" || observation.state === "uninspectable");
255
+ if (unavailable)
256
+ return {
257
+ status: 1,
258
+ outcomes,
259
+ message: `harness resource is ${unavailable.state}: ${unavailable.resource}`,
260
+ };
118
261
  const preparedState = prepared.result.outcome.result;
119
262
  const installedState = installed.result.outcome.result;
120
263
  return {
@@ -126,6 +269,13 @@ export async function gatherProbe(ctx) {
126
269
  installed: installedState,
127
270
  ownership: ownership.result.outcome.result,
128
271
  control: control.result.outcome.result,
272
+ compatibility: preparedState.compatibility,
273
+ resources: after,
274
+ resourceState: control.result.outcome.result.recoveryState === "required"
275
+ ? "recovery-required"
276
+ : after.some((observation) => observation.state === "owned")
277
+ ? "owned"
278
+ : "idle",
129
279
  status: preparedState.kind !== "current"
130
280
  ? "needs prepare"
131
281
  : installedState.kind !== "current"
@@ -227,9 +377,9 @@ export async function runProbe(argv, ctx) {
227
377
  // which catches every failure and returns "".
228
378
  //
229
379
  // A non-AdapterFailure re-thrown by runAdapter's closing `throw cause`
230
- // (src/adapter.ts) does NOT reach here: inspect() catches it and converts
380
+ // (src/harnesses/codex/adapter.ts) does NOT reach here: inspect() catches it and converts
231
381
  // it to a hand-written message per AGENTS.md's reader-diagnostics rule -- a
232
- // rethrown cause is exactly the failure src/adapter.ts declined to own, so
382
+ // rethrown cause is exactly the failure src/harnesses/codex/adapter.ts declined to own, so
233
383
  // its text must never reach this stream. See §3.3a.
234
384
  //
235
385
  // gatherProbe performs no writes of its own, so this catch cannot also be
@@ -253,5 +403,5 @@ export async function runProbe(argv, ctx) {
253
403
  }
254
404
  const rendered = ctx.adapter.presentation.renderProbe(outcome.facts);
255
405
  ctx.stdout.write(porcelain ? rendered.porcelain : rendered.human);
256
- return 0;
406
+ return outcome.facts.resourceState === "recovery-required" ? 1 : 0;
257
407
  }
@@ -1,9 +1,10 @@
1
1
  // Ports scripts/uninstall. The shell sourced common.sh, provenance.sh,
2
- // lifecycle.sh and adapter.sh; the predicates now live in src/lifecycle.ts
2
+ // lifecycle.sh and adapter.sh; the predicates now live in src/harnesses/codex/lifecycle.ts
3
3
  // and the adapter arrives through ctx.adapter.
4
4
  import { tmpdir } from "node:os";
5
5
  import { oneLine } from "../cli-arguments.js";
6
6
  import { withWorkspace, workspaceRemovalFailure } from "../workspace.js";
7
+ import { runWithMutation } from "./mutation.js";
7
8
  import { replayOutcome } from "./probe.js";
8
9
  async function invoke(call, failure, outcomes) {
9
10
  let result;
@@ -52,7 +53,7 @@ class GatherFailure extends Error {
52
53
  }
53
54
  // Every step that can throw or fail closed, returning the outcome as data and
54
55
  // performing NO writes. Same shape as gatherProbe
55
- // (`src/commands/probe.ts:114-117::readonly facts: ProbeSnapshot<R>;`) and for the same
56
+ // (`src/commands/probe.ts::readonly facts: ProbeSnapshot<R>;`) and for the same
56
57
  // reason: a write inside this try could raise EPIPE, be caught here, and be
57
58
  // relabelled as a domain failure.
58
59
  async function gatherUninstall(ctx) {
@@ -108,7 +109,7 @@ async function gatherUninstall(ctx) {
108
109
  return {
109
110
  status: 0,
110
111
  outcomes,
111
- output: ctx.adapter.presentation.renderRemovalCompletion(ownership),
112
+ output: ctx.adapter.presentation.renderRemovalCompletion(ownership, removalInput),
112
113
  };
113
114
  }, {
114
115
  // Suppresses withWorkspace's throw on a POST-SUCCESS cleanup failure,
@@ -125,7 +126,7 @@ async function gatherUninstall(ctx) {
125
126
  // Reachable only for mkdtemp failure, with nothing collected yet: this
126
127
  // callback never throws -- every ctx.adapter throw is already caught
127
128
  // inside invoke(), and presenceFlag/verifyUninstalledResources/
128
- // reportLegacyState are pure (src/lifecycle.ts's header comment) -- so a
129
+ // reportLegacyState are pure (src/harnesses/codex/lifecycle.ts's header comment) -- so a
129
130
  // post-success cleanup failure is handled by onCleanupFailure above and
130
131
  // cannot reach here. Wrapping with `outcomes` anyway keeps the class
131
132
  // total over its declared contract rather than assuming the callback's
@@ -134,6 +135,9 @@ async function gatherUninstall(ctx) {
134
135
  }
135
136
  }
136
137
  export async function runUninstall(argv, ctx) {
138
+ return await runWithMutation("uninstall", ctx, async (scoped) => performUninstall(argv, scoped));
139
+ }
140
+ async function performUninstall(argv, ctx) {
137
141
  // scripts/uninstall never reads "$@", so extra arguments are silently
138
142
  // ignored -- the same asymmetry runPrepare documents at its own
139
143
  // `void argv;`.
@@ -159,7 +163,7 @@ export async function runUninstall(argv, ctx) {
159
163
  // blindly.
160
164
  //
161
165
  // A cause outside ctx.adapter's AdapterFailure guard
162
- // (`src/adapter.ts:973-999::async function runCodexOperation(`) does NOT
166
+ // (`src/harnesses/codex/adapter.ts:973-999::async function runCodexOperation(`) does NOT
163
167
  // reach here: invoke() catches it inside gatherUninstall and converts it
164
168
  // to a hand-written message carried as UninstallOutcome data, exactly as
165
169
  // src/commands/probe.ts's inspect() does for the same cause.
@@ -1,5 +1,5 @@
1
1
  // Ports scripts/update. The shell sourced common.sh and lifecycle.sh; the
2
- // predicates now live in src/lifecycle.ts. Unlike install and uninstall,
2
+ // predicates now live in src/harnesses/codex/lifecycle.ts. Unlike install and uninstall,
3
3
  // update issues no ctx.adapter call of its own: every adapter interaction
4
4
  // this module needs already happens inside gatherProbe, runPrepare or
5
5
  // runInstall, each of which owns its own §4.2a-conformant invoke() gate. That
@@ -9,6 +9,7 @@ import { oneLine } from "../cli-arguments.js";
9
9
  import { gatherProbe, replayOutcome } from "./probe.js";
10
10
  import { runInstall } from "./install.js";
11
11
  import { runPrepare } from "./prepare.js";
12
+ import { runWithMutation } from "./mutation.js";
12
13
  function writeOutput(output, ctx) {
13
14
  for (const line of output.stdout)
14
15
  ctx.stdout.write(`${line}\n`);
@@ -16,6 +17,9 @@ function writeOutput(output, ctx) {
16
17
  ctx.stderr.write(`${line}\n`);
17
18
  }
18
19
  export async function runUpdate(argv, ctx) {
20
+ return await runWithMutation("update", ctx, async (scoped) => performUpdate(argv, scoped));
21
+ }
22
+ async function performUpdate(argv, ctx) {
19
23
  // scripts/update never reads "$@", so extra arguments are silently
20
24
  // ignored -- the same asymmetry runPrepare and runInstall document.
21
25
  void argv;
@@ -30,7 +34,7 @@ export async function runUpdate(argv, ctx) {
30
34
  // install's NOTE line.
31
35
  //
32
36
  // This is a THIRD consumer of gatherProbe's throw channel --
33
- // `src/commands/probe.ts:251-309::THREE exceptions, all inherited and none a regression:`'s
37
+ // `src/commands/probe.ts:469-527::THREE exceptions, all inherited and none a regression:`'s
34
38
  // runProbe catch is the first and src/commands/install.ts's runInstall catch
35
39
  // is the second. Because all three wrap the identical function, runProbe's
36
40
  // long comment there enumerates exactly what can reach this stream too; not
@@ -3,6 +3,10 @@ const SEMVER_BASE_SOURCE = "(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0
3
3
  "(?:\\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?";
4
4
  export const SEMVER_BASE_RE = new RegExp(`^${SEMVER_BASE_SOURCE}$`);
5
5
  export const TAG_RE = new RegExp(`^v${SEMVER_BASE_SOURCE}$`);
6
+ // The Python's own SEMVER_RE (:15-22). Deliberately not SEMVER_BASE_SOURCE
7
+ // from this module, which omits the `+build` component; widening the shared
8
+ // constant would change TAG_RE and is out of scope.
9
+ export 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-]+)*)?$/;
6
10
  export const COMMIT_RE = /^[0-9a-f]{40}$/;
7
11
  export const COMMIT_INPUT_RE = /^[0-9A-Fa-f]{40}$/;
8
12
  export function isTagRef(value) {
@@ -0,0 +1,13 @@
1
+ export function activationBlock(value, allowExperimental) {
2
+ switch (value.kind) {
3
+ case "supported":
4
+ return null;
5
+ case "experimental":
6
+ return allowExperimental
7
+ ? null
8
+ : "experimental integration requires --allow-experimental";
9
+ case "unknown":
10
+ case "unsupported":
11
+ return value.reason;
12
+ }
13
+ }
@@ -4,19 +4,20 @@ import { constants } from "node:fs";
4
4
  import { access, copyFile, mkdir, readFile, realpath, stat, writeFile, } from "node:fs/promises";
5
5
  import { tmpdir } from "node:os";
6
6
  import { delimiter, isAbsolute, join } from "node:path";
7
- import { AdapterMessageLog, failureResult, successResult, } from "./adapter-result.js";
8
- import { activePluginVersionFromJson, installedListingHas, marketplaceRootFromJson, } from "./codex-json.js";
9
- import { oneLine } from "./cli-arguments.js";
10
- import { installedCommitFromRoot, installedRootForVersion, pathsEqual, } from "./codex-state.js";
7
+ import { AdapterMessageLog, failureResult, successResult, } from "../../adapter-result.js";
8
+ import { activePluginVersionFromJson, installedListingHas, marketplaceRootFromJson, } from "./json.js";
9
+ import { CODEX_LEGACY_PLUGIN_ID, CODEX_MANAGER_PLUGIN_ID, inspectCodexConflicts, } from "./conflicts.js";
10
+ import { oneLine } from "../../cli-arguments.js";
11
+ import { installedCommitFromRoot, installedRootForVersion, pathsEqual, } from "./state.js";
11
12
  import { validateGeneratedPlugin } from "./generated-plugin.js";
12
13
  import { classifyHooks, materializeHooks, readManifest, } from "./hooks.js";
13
14
  import { applyManifestOverlay } from "./manifest-overlay.js";
14
- import { readCodexBuildSource } from "./provenance.js";
15
- import { isAcceptedSplitValue } from "./validate-generated-plugin-cli.js";
16
- import { withWorkspace, workspaceRemovalFailure } from "./workspace.js";
17
- const PLUGIN_ID = "superpowers@superpowers-manager";
15
+ import { readCodexBuildSource } from "../../provenance.js";
16
+ import { isAcceptedSplitValue } from "../../validate-generated-plugin-cli.js";
17
+ import { withWorkspace, workspaceRemovalFailure } from "../../workspace.js";
18
+ const PLUGIN_ID = CODEX_MANAGER_PLUGIN_ID;
18
19
  const MARKETPLACE_NAME = "superpowers-manager";
19
- const LEGACY_PLUGIN_ID = "superpowers@superpowers-wrapper";
20
+ const LEGACY_PLUGIN_ID = CODEX_LEGACY_PLUGIN_ID;
20
21
  const LEGACY_MARKETPLACE_NAME = "superpowers-wrapper";
21
22
  class AdapterFailure extends Error {
22
23
  code;
@@ -88,7 +89,7 @@ function runCommand(file, args, env) {
88
89
  });
89
90
  });
90
91
  }
91
- // Exported only so tests/unit/adapter.test.js can assert the env scrub
92
+ // Exported only so tests/unit/harnesses/codex/adapter.test.ts can assert the env scrub
92
93
  // directly. No production caller uses this name.
93
94
  export { runCommand as runCommandForTest };
94
95
  function commandFailed(result) {
@@ -280,7 +281,7 @@ async function runBuild(input, env, log) {
280
281
  // path — three are the frozen CPython wording, and the fourth (the
281
282
  // numeric-overflow diagnostic, which has no CPython oracle wording
282
283
  // to match) now carries the path via its own rewrap in
283
- // src/manifest-overlay.ts. Emit as-is, with no added prefix — a
284
+ // src/harnesses/codex/manifest-overlay.ts. Emit as-is, with no added prefix — a
284
285
  // prefix here would double up the path these messages already
285
286
  // name.
286
287
  log.appendText("stderr", oneLine(cause));
@@ -486,7 +487,7 @@ async function runUninstall(input, env, log) {
486
487
  throw cause;
487
488
  }
488
489
  }
489
- async function runInspect(view, env, log) {
490
+ async function runInspect(view, context, env, log) {
490
491
  if (view === "update-control") {
491
492
  return { view: "update-control", update_control: "managed" };
492
493
  }
@@ -552,9 +553,11 @@ async function runInspect(view, env, log) {
552
553
  let legacyPlugin;
553
554
  let managerMarketplace;
554
555
  let legacyMarketplace;
556
+ let conflicts;
555
557
  try {
556
558
  managerPlugin = installedListingHas(plugins.stdout, "installed", "pluginId", PLUGIN_ID);
557
559
  legacyPlugin = installedListingHas(plugins.stdout, "installed", "pluginId", LEGACY_PLUGIN_ID);
560
+ conflicts = await inspectCodexConflicts({ root: context.root, env }, plugins.stdout.toString("utf8"));
558
561
  }
559
562
  catch {
560
563
  fail("inspect-failed", `cannot parse output of '${codexBin} plugin list --json'`);
@@ -585,6 +588,7 @@ async function runInspect(view, env, log) {
585
588
  : legacyPresent
586
589
  ? "legacy"
587
590
  : "neither",
591
+ conflicts: [...conflicts],
588
592
  };
589
593
  }, { onCleanupFailure: reportOrphanedWorkspace(log) });
590
594
  }
@@ -622,7 +626,7 @@ export function codexRemove(input, context) {
622
626
  return runCodexOperation("uninstall", context, (env, log) => runUninstall(input, env, log));
623
627
  }
624
628
  export function codexInspect(view, context) {
625
- return runCodexOperation("inspect", context, (env, log) => runInspect(view, env, log));
629
+ return runCodexOperation("inspect", context, (env, log) => runInspect(view, context, env, log));
626
630
  }
627
631
  export async function runAdapter(argv, context) {
628
632
  const rawOperation = argv[0];