superpowers-manager 0.4.0 → 0.5.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.
@@ -1,14 +1,16 @@
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
@@ -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.
@@ -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
  }
@@ -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
@@ -48,10 +128,16 @@ export function replayOutcome(outcome, ctx) {
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"
@@ -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
  }
@@ -4,6 +4,7 @@
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,
@@ -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;`.
@@ -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
@@ -14,7 +14,7 @@ export const DEFAULT_FS_DEPS = {
14
14
  // The Python's own SEMVER_RE (:15-22). Deliberately not SEMVER_BASE_SOURCE
15
15
  // from src/domain/refs.ts, which omits the `+build` component; widening the
16
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-]+)*)?$/;
17
+ 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-]+)*)?$/;
18
18
  const MANIFEST_JSON_PROFILE = {
19
19
  duplicateKeys: "last-wins",
20
20
  nonStandardConstants: "reject",
@@ -444,7 +444,7 @@ const REQUIRED_FILES = [
444
444
  "CODE_OF_CONDUCT.md",
445
445
  ];
446
446
  /** Python `sorted()` orders by code point; JavaScript's default sort does not. */
447
- function compareByCodePoint(left, right) {
447
+ export function compareByCodePoint(left, right) {
448
448
  // `Array.from` splits by code point, exactly as spreading would; oxlint's
449
449
  // `no-misused-spread` rejects the spread form, and grapheme segmentation is
450
450
  // the wrong unit here — Python compares code points.
@@ -459,7 +459,7 @@ function compareByCodePoint(left, right) {
459
459
  }
460
460
  return leftPoints.length - rightPoints.length;
461
461
  }
462
- async function validateSkillFrontmatter(skillMd, skillName, errors, deps) {
462
+ export async function validateSkillFrontmatter(skillMd, skillName, errors, deps) {
463
463
  let contents;
464
464
  try {
465
465
  contents = STRICT_DECODER.decode(await deps.readFile(skillMd));
@@ -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
+ }
@@ -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) {
@@ -0,0 +1,73 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstat } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { readArtifactFile, readArtifactObject, validateNativeSkill, } from "./artifact-tree.js";
5
+ import { SEMVER_RE } from "./generated-plugin.js";
6
+ import { validateSource } from "./selection.js";
7
+ const OFFICIAL_SOURCES = new Set([
8
+ "https://github.com/obra/superpowers",
9
+ "https://github.com/obra/superpowers.git",
10
+ "ssh://git@github.com/obra/superpowers.git",
11
+ "git@github.com:obra/superpowers.git",
12
+ ]);
13
+ export function samePiSource(left, right) {
14
+ validateSource(left);
15
+ validateSource(right);
16
+ return (left === right ||
17
+ (OFFICIAL_SOURCES.has(left) && OFFICIAL_SOURCES.has(right)));
18
+ }
19
+ export async function assessPiCompatibility(root, selection) {
20
+ try {
21
+ validateSource(selection.effectiveSource);
22
+ const official = OFFICIAL_SOURCES.has(selection.effectiveSource);
23
+ const pkg = await readArtifactObject(root, join(root, "package.json"));
24
+ if (pkg.name !== "superpowers" ||
25
+ pkg.type !== "module" ||
26
+ typeof pkg.version !== "string" ||
27
+ !SEMVER_RE.test(pkg.version))
28
+ throw new Error("package metadata");
29
+ const pi = pkg.pi;
30
+ if (!pi ||
31
+ typeof pi !== "object" ||
32
+ Array.isArray(pi) ||
33
+ Object.keys(pi).length !== 2 ||
34
+ JSON.stringify(pi.extensions) !== '["./.pi/extensions/superpowers.ts"]' ||
35
+ JSON.stringify(pi.skills) !== '["./skills"]')
36
+ throw new Error("resource declaration");
37
+ for (const key of [
38
+ "dependencies",
39
+ "optionalDependencies",
40
+ "peerDependencies",
41
+ "bundleDependencies",
42
+ "bundledDependencies",
43
+ ]) {
44
+ const value = pkg[key];
45
+ if (value !== undefined &&
46
+ (value === null ||
47
+ typeof value !== "object" ||
48
+ Object.keys(value).length !== 0))
49
+ throw new Error("runtime dependencies");
50
+ }
51
+ await validateNativeSkill(root);
52
+ const bootstrap = join(root, ".pi/extensions/superpowers.ts");
53
+ const bytes = await readArtifactFile(root, bootstrap, 4283);
54
+ if ((await lstat(bootstrap)).mode & 0o111 ||
55
+ bytes.length !== 4283 ||
56
+ createHash("sha256").update(bytes).digest("hex") !==
57
+ "39c27000c047f8a1399a76e032e4cd239d7bdb0be168a121f46a2ac719deaae0")
58
+ throw new Error("bootstrap implementation");
59
+ return {
60
+ kind: official ? "supported" : "experimental",
61
+ generation: "pi-native-bootstrap-v1",
62
+ reason: official
63
+ ? "qualified upstream-native Pi bootstrap"
64
+ : "qualified Pi mechanics from a custom source",
65
+ };
66
+ }
67
+ catch {
68
+ return {
69
+ kind: "unsupported",
70
+ reason: `Pi package does not match the qualified native bootstrap profile: ${root}`,
71
+ };
72
+ }
73
+ }
@@ -0,0 +1,63 @@
1
+ import { failureResult, successResult } from "./adapter-result.js";
2
+ import { classifyPathNoFollow } from "./safe-path.js";
3
+ import { installPi, removePi } from "./pi-install.js";
4
+ import { piPaths } from "./pi-paths.js";
5
+ import { inspectPiPrepared, piPreparationLocation, preparePiCandidate, readPiPrepared, } from "./pi-prepare.js";
6
+ import { piPresentation } from "./pi-presentation.js";
7
+ import { inspectPiControl, inspectPiInstalled, inspectPiOwnership, } from "./pi-state.js";
8
+ export const piHarness = {
9
+ preparationLocation: piPreparationLocation,
10
+ mutationRoots: async (ctx) => [
11
+ piPaths(ctx.env ?? {}, process.cwd()).agentDir,
12
+ ],
13
+ validatePreparationBeforeFetch: async () => successResult("validate-pi-preparation", null, []),
14
+ prepareCandidate: preparePiCandidate,
15
+ inspectPrepared: inspectPiPrepared,
16
+ readPrepared: readPiPrepared,
17
+ inspectOwnership: inspectPiOwnership,
18
+ async inspectUpdateControl(ctx) {
19
+ const paths = piPaths(ctx.env ?? {}, process.cwd());
20
+ try {
21
+ if ((await classifyPathNoFollow(paths.recoveryRoot)) !== "missing") {
22
+ const decision = {
23
+ kind: "blocked",
24
+ output: {
25
+ stdout: [],
26
+ stderr: [
27
+ `error: Pi recovery required; preserve material at ${paths.recoveryRoot} for manual resolution`,
28
+ ],
29
+ },
30
+ };
31
+ return successResult("inspect-pi-control", {
32
+ probeEligibility: decision,
33
+ mutationEligibility: decision,
34
+ presentationValue: `recovery required at ${paths.recoveryRoot}`,
35
+ recoveryState: "required",
36
+ }, []);
37
+ }
38
+ }
39
+ catch {
40
+ return failureResult("inspect-pi-control", "invalid-state", `cannot inspect Pi recovery state at ${paths.recoveryRoot}`, [], []);
41
+ }
42
+ return await inspectPiControl(ctx);
43
+ },
44
+ inspectInstalled: inspectPiInstalled,
45
+ install: installPi,
46
+ remove: removePi,
47
+ requirements(command, env) {
48
+ if (command !== "install" &&
49
+ command !== "update" &&
50
+ command !== "uninstall")
51
+ return [];
52
+ const executable = env.SUPERPOWERS_PI || "pi";
53
+ return [
54
+ {
55
+ name: "pi",
56
+ executable,
57
+ lookup: "explicit-path-or-path",
58
+ missingMessage: `required command not found: ${executable} — install the Pi CLI or set SUPERPOWERS_PI`,
59
+ },
60
+ ];
61
+ },
62
+ presentation: piPresentation,
63
+ };