superpowers-manager 0.3.0 → 0.4.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,39 +1,14 @@
1
- import { cp, mkdir, rm, stat } from "node:fs/promises";
1
+ import { mkdir, stat } from "node:fs/promises";
2
2
  import { dirname, isAbsolute, join, resolve, 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
- import { readManifest } from "../hooks.js";
8
- import { writeProvenance } from "../provenance.js";
9
7
  import { SafetyError } from "../safety-error.js";
10
- import { manifestVersionForRef } from "../upstream-version.js";
11
8
  import { fetchExactCommit, gitSafeSource } from "../upstream.js";
12
9
  import { BOUNDED_EXECUTABLE, UNBOUNDED_LEGACY, launchFailureMessage, resolveValidator, runValidator, displayPath, } from "../validator.js";
13
10
  import { withWorkspace, workspaceRemovalFailure } from "../workspace.js";
14
11
  import { replayOutcome } from "./probe.js";
15
- // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/prepare:64-67::spw_require_upstream_path "$cache/skills`, via spw_require_upstream_path. Order is the shell's;
16
- // the first miss wins.
17
- const REQUIRED_UPSTREAM = [
18
- { path: "skills", label: "skills/" },
19
- { path: "LICENSE", label: "LICENSE" },
20
- { path: "README.md", label: "README.md" },
21
- { path: "CODE_OF_CONDUCT.md", label: "CODE_OF_CONDUCT.md" },
22
- ];
23
- // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/prepare:73-77::spw_copy_path_if_present "$cache/skills`. Same names on both sides.
24
- const COPY_PATHS = [
25
- "skills",
26
- "assets",
27
- "LICENSE",
28
- "README.md",
29
- "CODE_OF_CONDUCT.md",
30
- ];
31
- const RESOLUTION_KINDS = [
32
- "latest-release",
33
- "tag",
34
- "ref",
35
- "raw-commit",
36
- ];
37
12
  // Every message this module writes is hand-written here. The cause is attached
38
13
  // for debuggability and never reaches a stream: oneLine (src/cli-arguments.ts)
39
14
  // reads .message only. Same arrangement as hookError
@@ -41,28 +16,10 @@ const RESOLUTION_KINDS = [
41
16
  function prepareError(message, cause) {
42
17
  return new SafetyError("prepare", message, { cause });
43
18
  }
44
- // `[ -e ]` — follows symlinks, so a dangling link is absent to the shell too.
45
- // Two call sites, each mirroring a distinct `-e` in the shell: the
46
- // REQUIRED_UPSTREAM loop
47
- // (`git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/common.sh:53-59::spw_require_upstream_path`
48
- // — e.g. skills/ is a directory, not a regular file) and copyPathIfPresent's
49
- // guard (`git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/common.sh:44-51::spw_copy_path_if_present`). The `.git`
50
- // check below is NOT a third site: it needs `-d` (F1), not `-e`, and uses
51
- // directoryExists instead.
52
- async function pathExists(path) {
53
- try {
54
- await stat(path);
55
- return true;
56
- }
57
- catch {
58
- return false;
59
- }
60
- }
61
- // `[ -f ]` — regular file. `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/prepare:42::missing`, :80, and :108 all use -f, and
19
+ // `[ -f ]` — regular file. `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/prepare:108::[ -f "$additional_validator` uses -f, and
62
20
  // tests/baseline/cli-parity.test.js's "CLI-ENV-MANIFEST-TEMPLATE-01 fallback
63
- // template bytes and non-file rejection" asserts a DIRECTORY passed as
64
- // SUPERPOWERS_MANIFEST_TEMPLATE is rejected before any adapter build. A
65
- // stat-only predicate would accept it.
21
+ // template bytes and non-file rejection" also covers the separately extracted
22
+ // fallback check.
66
23
  async function regularFileExists(path) {
67
24
  try {
68
25
  return (await stat(path)).isFile();
@@ -84,8 +41,8 @@ async function directoryExists(path) {
84
41
  return false;
85
42
  }
86
43
  }
87
- // mkdir, cp, and rm throw raw ErrnoExceptions. Every prepare-owned call goes
88
- // through here so the message on the stream is this module's, not Node's.
44
+ // mkdir throws raw ErrnoExceptions. Every command-owned call goes through here
45
+ // so the message on the stream is this module's, not Node's.
89
46
  async function owned(message, fn) {
90
47
  try {
91
48
  return await fn();
@@ -94,50 +51,6 @@ async function owned(message, fn) {
94
51
  throw prepareError(message, cause);
95
52
  }
96
53
  }
97
- // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/common.sh:44-51::spw_copy_path_if_present`.
98
- // `cp -R` copies symlinks AS symlinks;
99
- // fs.cp's default rewrites relative link targets against the destination,
100
- // which would change what the adapter's containment checks see.
101
- async function copyPathIfPresent(source, destination) {
102
- if (!(await pathExists(source)))
103
- return;
104
- await owned(`cannot clear candidate path: ${destination}`, () => rm(destination, { recursive: true, force: true }));
105
- await owned(`cannot copy upstream path into candidate: ${source}`, () => cp(source, destination, { recursive: true, verbatimSymlinks: true }));
106
- }
107
- // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/prepare:17-24::case "$cache_parent`. Only cache_parent and plugin_root are resolved against
108
- // the invocation cwd; manifest_template (:14) is not.
109
- function resolveFromCwd(value, cwd) {
110
- return isAbsolute(value) ? value : resolve(cwd, value);
111
- }
112
- // readManifest (`src/hooks.ts:113::readManifest`) owns the read and the parse completely: byte
113
- // read so invalid UTF-8 is rejected rather than replaced, cause dropped, three
114
- // hand-written messages naming the path, object check included. Its
115
- // diagnostics are pinned by
116
- // `tests/unit/hooks.test.js:108::void test("readManifest diagnostics`. This wrapper adds only
117
- // the `version` type check.
118
- export async function readUpstreamManifestVersion(path) {
119
- const manifest = await readManifest(path);
120
- const value = manifest.version;
121
- // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/provenance.sh:56-62::for part in dotted_key.split`
122
- // — absent key and JSON null both yield "".
123
- if (value === undefined || value === null)
124
- return "";
125
- if (typeof value !== "string") {
126
- // The shell stringified any other type through Python's print()
127
- // (`git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/provenance.sh:62::print(value`), so `"version": 6` became "6" and flowed into both the
128
- // provenance record and --upstream-manifest-version. Fail closed instead.
129
- // Spec divergence 7.
130
- throw prepareError(`upstream manifest version is not a string: ${path}`);
131
- }
132
- return value;
133
- }
134
- function asResolutionKind(value) {
135
- for (const kind of RESOLUTION_KINDS) {
136
- if (kind === value)
137
- return kind;
138
- }
139
- throw prepareError(`unknown upstream resolution kind: ${value}`);
140
- }
141
54
  // Each stream's marker goes to that stream's own destination, so a reader of either
142
55
  // sees its own truncation and neither is silently short.
143
56
  function withTruncationMarker(captured, stream) {
@@ -167,16 +80,38 @@ function disclosureLine(resolution) {
167
80
  return `[superpowers-manager: running external validator ${configured}${via} -> ${displayPath(resolution.resolved)}]\n`;
168
81
  }
169
82
  const NO_VALIDATOR_OUTPUT = { stdout: "", stderr: "" };
83
+ function validStagingLeaf(value) {
84
+ return (value.length > 0 &&
85
+ value !== "." &&
86
+ value !== ".." &&
87
+ !value.includes("/") &&
88
+ !value.includes("\\"));
89
+ }
170
90
  async function gatherPrepare(ctx) {
171
91
  const env = ctx.env;
172
92
  // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/prepare:16::invocation_root=` — captured before the two case statements below.
173
93
  // getcwd(3) returns the physical path, so this matches `pwd -P` without a
174
94
  // realpath call.
175
95
  const cwd = process.cwd();
176
- const cacheParent = resolveFromCwd(env.SUPERPOWERS_CACHE_DIR || join(ctx.root, ".cache", "upstream"), cwd);
177
- const pluginRoot = resolveFromCwd(env.SUPERPOWERS_PLUGIN_ROOT || join(ctx.root, "plugins", "superpowers"), cwd);
178
- const manifestTemplate = env.SUPERPOWERS_MANIFEST_TEMPLATE ||
179
- join(ctx.root, "plugins", "superpowers", ".codex-plugin", "plugin.template.json");
96
+ const configuredCache = env.SUPERPOWERS_CACHE_DIR || join(ctx.root, ".cache", "upstream");
97
+ const cacheParent = isAbsolute(configuredCache)
98
+ ? configuredCache
99
+ : resolve(cwd, configuredCache);
100
+ const adapterContext = { root: ctx.root, env };
101
+ let location;
102
+ try {
103
+ location = ctx.adapter.preparationLocation(adapterContext);
104
+ }
105
+ catch (cause) {
106
+ throw prepareError("cannot determine preparation location", cause);
107
+ }
108
+ if (!isAbsolute(location.destinationRoot)) {
109
+ throw prepareError("adapter returned a non-absolute preparation destination");
110
+ }
111
+ if (!validStagingLeaf(location.stagingLeaf)) {
112
+ throw prepareError("adapter returned an invalid preparation staging leaf");
113
+ }
114
+ const pluginRoot = location.destinationRoot;
180
115
  const additionalValidator = env.SUPERPOWERS_VALIDATOR || "";
181
116
  const executableValidator = env.SUPERPOWERS_VALIDATOR_EXECUTABLE || "";
182
117
  const cache = join(cacheParent, "superpowers");
@@ -184,17 +119,30 @@ async function gatherPrepare(ctx) {
184
119
  await owned(`cannot create directory: ${tmpParent}`, () => mkdir(tmpParent, { recursive: true }));
185
120
  let cleanupWarning = null;
186
121
  const outcome = await withWorkspace(tmpParent, ".superpowers.prepare.", async (workspace) => {
122
+ const outcomes = [];
187
123
  const failed = (message) => ({
188
124
  kind: "failed",
189
- outcomes: [],
125
+ outcomes,
190
126
  validator: NO_VALIDATOR_OUTPUT,
191
127
  message,
192
128
  });
193
- const candidate = join(workspace, "superpowers");
129
+ const candidate = join(workspace, location.stagingLeaf);
194
130
  const selection = await computeEffectiveSelection(ctx.root, env);
195
- // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/prepare:42::missing` — `[ -f ]`.
196
- if (!(await regularFileExists(manifestTemplate))) {
197
- return failed(`missing fallback manifest template: ${manifestTemplate}`);
131
+ let prefetch;
132
+ try {
133
+ prefetch =
134
+ await ctx.adapter.validatePreparationBeforeFetch(adapterContext);
135
+ }
136
+ catch {
137
+ return failed(ctx.adapter.presentation.callFailure("prepare", adapterContext)
138
+ .unexpected);
139
+ }
140
+ outcomes.push(prefetch.outcome);
141
+ if (!prefetch.outcome.ok)
142
+ return failed(null);
143
+ if (prefetch.status !== 0) {
144
+ return failed(ctx.adapter.presentation.callFailure("prepare", adapterContext)
145
+ .invalidStatus);
198
146
  }
199
147
  await owned(`cannot create directory: ${cacheParent}`, () => mkdir(cacheParent, { recursive: true }));
200
148
  if (selection.selectionMode === "pinned") {
@@ -237,86 +185,31 @@ async function gatherPrepare(ctx) {
237
185
  if (checkedOut.status !== 0) {
238
186
  return failed(`cannot check out upstream commit: ${selection.desiredCommit}`);
239
187
  }
240
- for (const required of REQUIRED_UPSTREAM) {
241
- // spw_require_upstream_path is `[ -e ]`, not `[ -f ]`: skills/ is a
242
- // directory.
243
- if (!(await pathExists(join(cache, required.path)))) {
244
- return failed(`required upstream path missing: ${required.label}`);
245
- }
246
- }
247
- await owned(`cannot clear candidate root: ${candidate}`, () => rm(candidate, { recursive: true, force: true }));
248
- await owned(`cannot create candidate root: ${candidate}`, () => mkdir(join(candidate, ".codex-plugin"), { recursive: true }));
249
- for (const name of COPY_PATHS) {
250
- await copyPathIfPresent(join(cache, name), join(candidate, name));
251
- }
252
- const upstreamManifest = join(cache, ".codex-plugin", "plugin.json");
253
- let upstreamManifestVersion = "";
254
- // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/prepare:80::if [ -f` — `[ -f ]`.
255
- if (await regularFileExists(upstreamManifest)) {
256
- upstreamManifestVersion =
257
- await readUpstreamManifestVersion(upstreamManifest);
258
- }
259
- await writeProvenance(join(candidate, ".superpowers-upstream.json"), {
260
- source: selection.effectiveSource,
261
- requested_ref: selection.requestedRef,
262
- resolved_ref: selection.resolvedRef,
263
- commit: selection.desiredCommit,
264
- upstream_manifest_version: upstreamManifestVersion,
265
- });
266
- const managerVersion = manifestVersionForRef({
267
- requestedRef: selection.requestedRef,
268
- resolutionKind: asResolutionKind(selection.resolutionKind),
269
- resolvedRef: selection.resolvedRef,
270
- commit: selection.desiredCommit,
271
- });
272
- let built;
188
+ let prepared;
273
189
  try {
274
- built = await ctx.adapter([
275
- "build",
276
- "--upstream-root",
277
- cache,
278
- "--candidate-root",
279
- candidate,
280
- "--requested-ref",
281
- selection.requestedRef,
282
- "--resolved-ref",
283
- selection.resolvedRef,
284
- "--commit",
285
- selection.desiredCommit,
286
- "--manager-version",
287
- managerVersion,
288
- "--upstream-manifest-version",
289
- upstreamManifestVersion,
290
- "--fallback-manifest",
291
- manifestTemplate,
292
- ], { root: ctx.root, env });
190
+ prepared = await ctx.adapter.prepareCandidate({
191
+ upstreamRoot: cache,
192
+ workspaceRoot: workspace,
193
+ candidateRoot: candidate,
194
+ selection,
195
+ }, adapterContext);
293
196
  }
294
197
  catch {
295
- // ctx.adapter reports CONTROLLED failures by return value
296
- // (`src/adapter-result.ts:32-35::export interface AdapterResult`) but still THROWS for a
297
- // non-AdapterFailure cause (runAdapter's closing `throw cause`,
298
- // src/adapter.ts). That cause is by construction the one failure
299
- // src/adapter.ts declined to own, so its text must never reach
300
- // ctx.stderr. Caught here rather than in runPrepare's outer catch,
301
- // the same treatment
302
- // `src/commands/probe.ts:205-228::It does still THROW for a non-AdapterFailure cause`
303
- // gives it.
304
- return failed("cannot build the generated plugin candidate");
198
+ return failed(ctx.adapter.presentation.callFailure("prepare", adapterContext)
199
+ .unexpected);
305
200
  }
306
- const outcomes = [built.outcome];
307
- if (built.status !== 0 || !built.outcome.ok) {
308
- return {
309
- kind: "failed",
310
- outcomes,
311
- validator: NO_VALIDATOR_OUTPUT,
312
- // replayOutcome emits the adapter's own error and hints; a second
313
- // line here would duplicate them. The `ok && status !== 0`
314
- // combination cannot arise from successResult/failureResult, so it
315
- // gets its own hand-written message rather than a silent replay.
316
- message: built.outcome.ok
317
- ? "adapter reported failure without an error outcome"
318
- : null,
319
- };
201
+ outcomes.push(prepared.outcome);
202
+ if (prepared.status !== 0 || !prepared.outcome.ok) {
203
+ return failed(prepared.outcome.ok
204
+ ? ctx.adapter.presentation.callFailure("prepare", adapterContext)
205
+ .invalidStatus
206
+ : null);
207
+ }
208
+ if (prepared.outcome.result.root !== candidate) {
209
+ return failed("adapter returned an unexpected preparation root");
210
+ }
211
+ if (prepared.outcome.result.commit !== selection.desiredCommit) {
212
+ return failed("adapter returned an unexpected preparation commit");
320
213
  }
321
214
  let validator = NO_VALIDATOR_OUTPUT;
322
215
  if (additionalValidator.length > 0) {
@@ -443,7 +336,7 @@ export async function runPrepare(argv, ctx) {
443
336
  // spawn error, which oneLine never reads -- it takes .message only);
444
337
  // readManifest's three hookError messages
445
338
  // (`src/hooks.ts:113-138::readManifest`), pinned by
446
- // `tests/unit/hooks.test.js:108::void test("readManifest diagnostics` as carrying no reader vocabulary or
339
+ // `tests/unit/hooks.test.ts:95::void test("readManifest diagnostics` as carrying no reader vocabulary or
447
340
  // errno; and SafetyErrors from gitSafeSource, writeProvenance, and
448
341
  // withWorkspace.
449
342
  //
@@ -454,7 +347,7 @@ export async function runPrepare(argv, ctx) {
454
347
  // This is the DEFAULT invocation -- plain `prepare`, `track-latest`,
455
348
  // and any non-40-hex SUPERPOWERS_REF -- not an exotic corner. Pinned
456
349
  // by
457
- // `tests/unit/upstream.test.js:460-469::void test("resolveRef reports a query failure for latest`,
350
+ // `tests/unit/upstream.test.ts:453-462::void test("resolveRef reports a query failure for latest`,
458
351
  // :471-481, and :483-501.
459
352
  // 2. fetchExactCommit splices the same combined stdout+stderr into its
460
353
  // own text on the PINNED path (both of its own splice sites in
@@ -519,7 +412,7 @@ export async function runPrepare(argv, ctx) {
519
412
  // completed before cleanup ran, so it is not being reported as unverified
520
413
  // -- but something did still go wrong, and AGENTS.md's fail-closed rule
521
414
  // extends to it. Mirrors
522
- // `src/commands/install.ts:503-510::if (cleanupWarning`.
415
+ // `src/commands/install.ts:395-403::if (cleanupWarning`.
523
416
  ctx.stderr.write(`error: ${cleanupWarning}\n`);
524
417
  return 1;
525
418
  }
@@ -1,115 +1,7 @@
1
1
  import { assertFailureWritable, writeAdapterFailure, } from "../adapter-result.js";
2
2
  import { oneLine } from "../cli-arguments.js";
3
3
  import { computeEffectiveSelection } from "../effective-selection.js";
4
- import { generatedCommitOrEmpty } from "../provenance.js";
5
- import { displaySource } from "../selection.js";
6
- import { statusForCommits } from "../status.js";
7
4
  export const PROBE_USAGE = "error: usage: superpowers-manager probe [--porcelain]\n";
8
- // One ordered table drives both formats, so the porcelain key order
9
- // (`git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/probe:43-59::printf 'requested_ref`) and the human label order (`git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/probe:61-77::printf 'requested ref`) cannot
10
- // drift apart. Two parallel lists could each stay self-consistent while
11
- // disagreeing with one another.
12
- function fields(f) {
13
- return [
14
- { key: "requested_ref", label: "requested ref", value: f.requestedRef },
15
- { key: "resolved_ref", label: "resolved ref", value: f.resolvedRef },
16
- { key: "desired_commit", label: "desired commit", value: f.desiredCommit },
17
- {
18
- key: "generated_commit",
19
- label: "generated plugin commit",
20
- value: f.generatedCommit,
21
- absent: "not present",
22
- },
23
- {
24
- key: "installed_commit",
25
- label: "installed manager commit or fingerprint",
26
- value: f.installedCommit,
27
- absent: "not detected",
28
- },
29
- {
30
- key: "identity_state",
31
- label: "Codex identity state",
32
- value: f.identityState,
33
- },
34
- { key: "status", label: "status", value: f.status },
35
- {
36
- key: "selection_origin",
37
- label: "selection origin",
38
- value: f.selectionOrigin,
39
- },
40
- { key: "selection_mode", label: "selection mode", value: f.selectionMode },
41
- {
42
- key: "upstream_source_origin",
43
- label: "upstream source origin",
44
- value: f.upstreamSourceOrigin,
45
- },
46
- {
47
- key: "effective_source",
48
- label: "effective source",
49
- value: f.effectiveSource,
50
- },
51
- { key: "saved_mode", label: "saved mode", value: f.savedMode },
52
- { key: "saved_source", label: "saved source", value: f.savedSource },
53
- {
54
- key: "saved_requested_ref",
55
- label: "saved requested ref",
56
- value: f.savedRequestedRef,
57
- },
58
- {
59
- key: "saved_resolved_ref",
60
- label: "saved resolved ref",
61
- value: f.savedResolvedRef,
62
- },
63
- { key: "saved_commit", label: "saved commit", value: f.savedCommit },
64
- { key: "update_control", label: "update control", value: f.updateControl },
65
- ];
66
- }
67
- // Derived from the same fields() table, so the key list a test asserts and
68
- // the key list formatPorcelain emits cannot be different lists. The facts
69
- // argument is irrelevant here — fields() is total over ProbeFacts and the
70
- // keys do not depend on the values.
71
- const NO_FACTS = {
72
- requestedRef: "",
73
- resolvedRef: "",
74
- desiredCommit: "",
75
- generatedCommit: "",
76
- installedCommit: "",
77
- identityState: "",
78
- status: "",
79
- selectionOrigin: "",
80
- selectionMode: "",
81
- upstreamSourceOrigin: "",
82
- effectiveSource: "",
83
- savedMode: "",
84
- savedSource: "",
85
- savedRequestedRef: "",
86
- savedResolvedRef: "",
87
- savedCommit: "",
88
- updateControl: "",
89
- };
90
- export const PROBE_PORCELAIN_KEYS = fields(NO_FACTS).map((field) => field.key);
91
- export function formatPorcelain(f) {
92
- return fields(f)
93
- .map((field) => `${field.key}=${field.value}\n`)
94
- .join("");
95
- }
96
- export function formatHuman(f) {
97
- let text = fields(f)
98
- .map((field) => {
99
- const shown = field.value.length === 0 && field.absent !== undefined
100
- ? field.absent
101
- : field.value;
102
- return `${field.label}: ${shown}\n`;
103
- })
104
- .join("");
105
- // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/probe:78-81::if [ "$SPW_SELECTION_ORIGIN`.
106
- if (f.selectionOrigin !== f.upstreamSourceOrigin) {
107
- text +=
108
- "warning: effective ref and source have mixed origins " +
109
- `(ref: ${f.selectionOrigin}, source: ${f.upstreamSourceOrigin})\n`;
110
- }
111
- return text;
112
- }
113
5
  // Ports scripts/core/validate-adapter-response.py's replay (:235-238) and its
114
6
  // error/hint block (:269-272). The shell ran that validator on EVERY adapter
115
7
  // response, so adapter messages reached the operator on their declared streams
@@ -156,25 +48,13 @@ export function replayOutcome(outcome, ctx) {
156
48
  // adapter module: src/commands/prepare.ts and this module are the two the
157
49
  // injected double must observe, because install reaches the adapter through
158
50
  // gatherProbe and runPrepare. Spec §4.5.
159
- async function inspect(view, key, ctx) {
51
+ async function inspect(call, unexpected, invalidStatus) {
160
52
  let result;
161
53
  try {
162
- result = await ctx.adapter(["inspect", "--view", view], {
163
- root: ctx.root,
164
- env: ctx.env,
165
- });
54
+ result = await call();
166
55
  }
167
56
  catch {
168
- // Deliberately does NOT interpolate the cause. A rethrown non-
169
- // AdapterFailure is by construction the one failure src/adapter.ts chose
170
- // not to own: free-form text of unknown provenance, which AGENTS.md bars
171
- // from this stream. `view` is a bounded token -- one of three literals
172
- // this function is ever called with -- so naming the input is safe.
173
- return {
174
- ok: false,
175
- outcome: null,
176
- message: `cannot inspect Codex adapter state for view ${view}`,
177
- };
57
+ return { ok: false, result: null, message: unexpected };
178
58
  }
179
59
  const outcome = result.outcome;
180
60
  if (result.status !== 0 || !outcome.ok) {
@@ -183,27 +63,11 @@ async function inspect(view, key, ctx) {
183
63
  // rather than falling through to a replay that would print nothing.
184
64
  return {
185
65
  ok: false,
186
- outcome,
187
- message: outcome.ok
188
- ? `adapter reported a failure status for inspect --view ${view}`
189
- : null,
190
- };
191
- }
192
- const value = outcome.result?.[key];
193
- // The Python reader printed the empty string for a JSON null
194
- // (scripts/core/provenance.sh's spw_json_get), and `fingerprint` is null
195
- // whenever no plugin version is active (`src/adapter.ts:820::fingerprint: null`).
196
- if (value === null || value === undefined) {
197
- return { ok: true, value: "", outcome };
198
- }
199
- if (typeof value !== "string") {
200
- return {
201
- ok: false,
202
- outcome,
203
- message: `adapter returned a non-string ${key} for inspect --view ${view}`,
66
+ result,
67
+ message: outcome.ok ? invalidStatus : null,
204
68
  };
205
69
  }
206
- return { ok: true, value, outcome };
70
+ return { ok: true, result: { status: result.status, outcome } };
207
71
  }
208
72
  // Runs every step that can throw or fail closed, returning the outcome as
209
73
  // data. It performs no writes of its own, so nothing inside runProbe's try can
@@ -223,50 +87,50 @@ async function inspect(view, key, ctx) {
223
87
  export async function gatherProbe(ctx) {
224
88
  // Order mirrors `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/probe:24-40::spw_compute_effective_selection` exactly.
225
89
  const selection = await computeEffectiveSelection(ctx.root, ctx.env);
226
- const generatedCommit = await generatedCommitOrEmpty(ctx.root);
227
90
  const outcomes = [];
228
- const collect = async (view, key) => {
229
- const result = await inspect(view, key, ctx);
230
- if (result.outcome !== null)
231
- outcomes.push(result.outcome);
91
+ 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)
95
+ outcomes.push(result.result.outcome);
232
96
  return result;
233
97
  };
234
- const fingerprint = await collect("fingerprint", "fingerprint");
235
- if (!fingerprint.ok) {
236
- return { status: 1, outcomes, message: fingerprint.message };
98
+ const prepared = await collect(() => ctx.adapter.inspectPrepared(selection, adapterContext), "cannot inspect prepared harness state", "adapter reported a failure status for prepared harness inspection");
99
+ if (!prepared.ok) {
100
+ return { status: 1, outcomes, message: prepared.message };
101
+ }
102
+ const failure = (site) => ctx.adapter.presentation.callFailure(site, adapterContext);
103
+ const installedFailure = failure("probe-installed");
104
+ const installed = await collect(() => ctx.adapter.inspectInstalled(selection, adapterContext), installedFailure.unexpected, installedFailure.invalidStatus);
105
+ if (!installed.ok) {
106
+ return { status: 1, outcomes, message: installed.message };
237
107
  }
238
- const ownership = await collect("ownership", "identity_state");
108
+ const ownershipFailure = failure("probe-ownership");
109
+ const ownership = await collect(() => ctx.adapter.inspectOwnership(adapterContext), ownershipFailure.unexpected, ownershipFailure.invalidStatus);
239
110
  if (!ownership.ok) {
240
111
  return { status: 1, outcomes, message: ownership.message };
241
112
  }
242
- const updateControl = await collect("update-control", "update_control");
243
- if (!updateControl.ok) {
244
- return { status: 1, outcomes, message: updateControl.message };
113
+ const controlFailure = failure("probe-control");
114
+ const control = await collect(() => ctx.adapter.inspectUpdateControl(adapterContext), controlFailure.unexpected, controlFailure.invalidStatus);
115
+ if (!control.ok) {
116
+ return { status: 1, outcomes, message: control.message };
245
117
  }
246
- const saved = selection.saved;
118
+ const preparedState = prepared.result.outcome.result;
119
+ const installedState = installed.result.outcome.result;
247
120
  return {
248
121
  status: 0,
249
122
  outcomes,
250
123
  facts: {
251
- requestedRef: selection.requestedRef,
252
- resolvedRef: selection.resolvedRef,
253
- desiredCommit: selection.desiredCommit,
254
- generatedCommit,
255
- installedCommit: fingerprint.value,
256
- identityState: ownership.value,
257
- status: statusForCommits(selection.desiredCommit, generatedCommit, fingerprint.value),
258
- selectionOrigin: selection.selectionOrigin,
259
- selectionMode: selection.selectionMode,
260
- upstreamSourceOrigin: selection.upstreamSourceOrigin,
261
- effectiveSource: displaySource(selection.effectiveSource),
262
- savedMode: saved.saved_mode,
263
- // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/probe:26-30::if [ -`: an absent saved source stays empty rather than
264
- // being run through displaySource, which would render <redacted-source>.
265
- savedSource: saved.saved_source.length > 0 ? displaySource(saved.saved_source) : "",
266
- savedRequestedRef: saved.saved_requested_ref,
267
- savedResolvedRef: saved.saved_resolved_ref,
268
- savedCommit: saved.saved_commit,
269
- updateControl: updateControl.value,
124
+ selection,
125
+ prepared: preparedState,
126
+ installed: installedState,
127
+ ownership: ownership.result.outcome.result,
128
+ control: control.result.outcome.result,
129
+ status: preparedState.kind !== "current"
130
+ ? "needs prepare"
131
+ : installedState.kind !== "current"
132
+ ? "needs install"
133
+ : "current",
270
134
  },
271
135
  };
272
136
  }
@@ -319,7 +183,7 @@ export async function runProbe(argv, ctx) {
319
183
  // every unpinned run does: resolveRef's COMMIT_INPUT_RE branch returns
320
184
  // before any runGit call, so a 40-hex requested ref splices nothing.
321
185
  // Pinned by
322
- // `tests/unit/upstream.test.js:460::void test("resolveRef reports a query failure for latest`,
186
+ // `tests/unit/upstream.test.ts:453::void test("resolveRef reports a query failure for latest`,
323
187
  // :471, and :483.
324
188
  // 2. The
325
189
  // `src/selection-store.ts:120-124::cause.module === "selection") {`
@@ -387,6 +251,7 @@ export async function runProbe(argv, ctx) {
387
251
  }
388
252
  return 1;
389
253
  }
390
- ctx.stdout.write(porcelain ? formatPorcelain(outcome.facts) : formatHuman(outcome.facts));
254
+ const rendered = ctx.adapter.presentation.renderProbe(outcome.facts);
255
+ ctx.stdout.write(porcelain ? rendered.porcelain : rendered.human);
391
256
  return 0;
392
257
  }