superpowers-manager 0.5.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +44 -26
  2. package/dist/adapter-result.js +5 -9
  3. package/dist/atomic.js +4 -14
  4. package/dist/cli-arguments.js +1 -27
  5. package/dist/cli.js +32 -52
  6. package/dist/commands/adapter-call.js +28 -0
  7. package/dist/commands/install.js +26 -107
  8. package/dist/commands/mutation.js +7 -0
  9. package/dist/commands/pin.js +1 -1
  10. package/dist/commands/prepare.js +39 -129
  11. package/dist/commands/probe.js +35 -264
  12. package/dist/commands/track-latest.js +1 -1
  13. package/dist/commands/uninstall.js +16 -92
  14. package/dist/commands/update.js +5 -25
  15. package/dist/effective-selection.js +7 -9
  16. package/dist/git-tree.js +85 -0
  17. package/dist/harness-command-result.js +47 -0
  18. package/dist/harnesses/codex/adapter.js +231 -510
  19. package/dist/harnesses/codex/compatibility.js +2 -17
  20. package/dist/harnesses/codex/conflicts.js +2 -3
  21. package/dist/harnesses/codex/harness.js +18 -263
  22. package/dist/harnesses/codex/hooks.js +1 -1
  23. package/dist/harnesses/codex/json.js +1 -11
  24. package/dist/harnesses/codex/lifecycle.js +80 -72
  25. package/dist/harnesses/codex/manifest-overlay.js +23 -18
  26. package/dist/harnesses/codex/paths.js +14 -29
  27. package/dist/harnesses/codex/presentation.js +8 -37
  28. package/dist/harnesses/codex/publication.js +13 -37
  29. package/dist/harnesses/codex/recovery.js +5 -5
  30. package/dist/harnesses/codex/stored-state.js +1 -1
  31. package/dist/harnesses/opencode/config.js +256 -0
  32. package/dist/harnesses/opencode/discovery.js +587 -0
  33. package/dist/harnesses/opencode/harness.js +37 -0
  34. package/dist/harnesses/opencode/install.js +601 -0
  35. package/dist/harnesses/opencode/native.js +57 -0
  36. package/dist/harnesses/opencode/package.js +89 -0
  37. package/dist/harnesses/opencode/paths.js +38 -0
  38. package/dist/harnesses/opencode/prepare.js +30 -0
  39. package/dist/harnesses/opencode/presentation.js +28 -0
  40. package/dist/harnesses/opencode/state.js +174 -0
  41. package/dist/harnesses/pi/compatibility.js +5 -37
  42. package/dist/harnesses/pi/install.js +7 -5
  43. package/dist/harnesses/pi/native.js +3 -47
  44. package/dist/harnesses/pi/package.js +5 -107
  45. package/dist/harnesses/pi/prepare.js +13 -104
  46. package/dist/harnesses/pi/presentation.js +23 -66
  47. package/dist/harnesses/pi/shared-skills.js +28 -39
  48. package/dist/harnesses/pi/state.js +7 -27
  49. package/dist/provenance.js +2 -2
  50. package/dist/python-json-format.js +0 -69
  51. package/dist/python-json.js +2 -27
  52. package/dist/python-text.js +9 -69
  53. package/dist/safe-path.js +2 -2
  54. package/dist/selection-store.js +2 -22
  55. package/dist/snapshot-package.js +78 -0
  56. package/dist/snapshot-prepare.js +99 -0
  57. package/dist/snapshot-presentation.js +44 -0
  58. package/dist/validator.js +39 -73
  59. package/node_modules/jsonc-parser/CHANGELOG.md +76 -0
  60. package/node_modules/jsonc-parser/LICENSE.md +21 -0
  61. package/node_modules/jsonc-parser/README.md +364 -0
  62. package/node_modules/jsonc-parser/SECURITY.md +41 -0
  63. package/node_modules/jsonc-parser/lib/esm/impl/edit.js +185 -0
  64. package/node_modules/jsonc-parser/lib/esm/impl/format.js +261 -0
  65. package/node_modules/jsonc-parser/lib/esm/impl/parser.js +659 -0
  66. package/node_modules/jsonc-parser/lib/esm/impl/scanner.js +443 -0
  67. package/node_modules/jsonc-parser/lib/esm/impl/string-intern.js +29 -0
  68. package/node_modules/jsonc-parser/lib/esm/main.d.ts +351 -0
  69. package/node_modules/jsonc-parser/lib/esm/main.js +178 -0
  70. package/node_modules/jsonc-parser/lib/umd/impl/edit.js +201 -0
  71. package/node_modules/jsonc-parser/lib/umd/impl/format.js +275 -0
  72. package/node_modules/jsonc-parser/lib/umd/impl/parser.js +682 -0
  73. package/node_modules/jsonc-parser/lib/umd/impl/scanner.js +456 -0
  74. package/node_modules/jsonc-parser/lib/umd/impl/string-intern.js +42 -0
  75. package/node_modules/jsonc-parser/lib/umd/main.d.ts +351 -0
  76. package/node_modules/jsonc-parser/lib/umd/main.js +194 -0
  77. package/node_modules/jsonc-parser/package.json +37 -0
  78. package/package.json +8 -4
  79. package/dist/domain/fingerprint.js +0 -4
  80. package/dist/selection-state-cli.js +0 -85
  81. package/dist/status.js +0 -24
  82. package/dist/upstream-cli.js +0 -110
  83. package/dist/validate-generated-plugin-cli.js +0 -118
@@ -3,6 +3,7 @@ import { upstreamCacheRoot } from "../upstream-workspace.js";
3
3
  import { SafetyError } from "../safety-error.js";
4
4
  import { isAbsolute } from "node:path";
5
5
  import { oneLine } from "../cli-arguments.js";
6
+ import { configurationErrors } from "../validator.js";
6
7
  export async function withMutation(command, ctx, action) {
7
8
  // Selection validation precedes every adapter call and lock creation. A
8
9
  // nested mutation receives the already resolved value and cannot observe a
@@ -50,6 +51,12 @@ export async function withMutation(command, ctx, action) {
50
51
  return await ctx.coordination.withResources(resources, async () => action(scoped));
51
52
  }
52
53
  export async function runWithMutation(command, ctx, action) {
54
+ const errors = configurationErrors(command, ctx.env);
55
+ if (errors.length > 0) {
56
+ for (const message of errors)
57
+ ctx.stderr.write(`error: ${message}\n`);
58
+ return 1;
59
+ }
53
60
  let actionThrew = false;
54
61
  try {
55
62
  return await withMutation(command, ctx, async (scoped) => {
@@ -17,7 +17,7 @@ async function attemptPin(requested, ctx) {
17
17
  // writing, under "Invalid existing state must block overwrite" —
18
18
  // readSelectionState's own readOpenedRecord/parseRecordBytes is what
19
19
  // validates an existing record, via validateRecord at
20
- // `src/selection-store.ts:103::return validateRecord`). Calling loadSavedSelection here preserves
20
+ // `src/selection-store.ts:99::return validateRecord`). Calling loadSavedSelection here preserves
21
21
  // `git show 349fe2ed405b371ec2de1347bb3fc50c6bc15dc4:scripts/pin:26-27::read`'s read-then-resolve-then-write shape, so this command
22
22
  // stays fail-closed on its own terms rather than solely by depending on the
23
23
  // store's internals.
@@ -7,7 +7,7 @@ import { runGit } from "../git.js";
7
7
  import { SafetyError } from "../safety-error.js";
8
8
  import { fetchExactCommit, gitSafeSource } from "../upstream.js";
9
9
  import { upstreamCacheRoot } from "../upstream-workspace.js";
10
- import { BOUNDED_EXECUTABLE, UNBOUNDED_LEGACY, launchFailureMessage, resolveValidator, runValidator, displayPath, } from "../validator.js";
10
+ import { BOUNDED_EXECUTABLE, launchFailureMessage, resolveValidator, runValidator, displayPath, } from "../validator.js";
11
11
  import { withWorkspace, workspaceRemovalFailure } from "../workspace.js";
12
12
  import { replayOutcome } from "./probe.js";
13
13
  import { runWithMutation } from "./mutation.js";
@@ -18,18 +18,6 @@ import { runWithMutation } from "./mutation.js";
18
18
  function prepareError(message, cause) {
19
19
  return new SafetyError("prepare", message, { cause });
20
20
  }
21
- // `[ -f ]` — regular file. `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/prepare:108::[ -f "$additional_validator` uses -f, and
22
- // tests/baseline/cli-parity.test.js's "CLI-ENV-MANIFEST-TEMPLATE-01 fallback
23
- // template bytes and non-file rejection" also covers the separately extracted
24
- // fallback check.
25
- async function regularFileExists(path) {
26
- try {
27
- return (await stat(path)).isFile();
28
- }
29
- catch {
30
- return false;
31
- }
32
- }
33
21
  // `[ -d ]` — `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/prepare:50::if [ -d`. A regular file named `.git` is what a git
34
22
  // worktree or `clone --separate-git-dir` leaves behind; `-e` would take the
35
23
  // fetch branch and let git follow its `gitdir:` pointer, where the shell took
@@ -90,10 +78,9 @@ function validStagingLeaf(value) {
90
78
  !value.includes("\\"));
91
79
  }
92
80
  async function gatherPrepare(ctx) {
81
+ // Collect outcomes without writing so output failures cannot be classified as
82
+ // inspection failures. Replay collected outcomes after gathering completes.
93
83
  const env = ctx.env;
94
- // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/prepare:16::invocation_root=` — captured before the two case statements below.
95
- // getcwd(3) returns the physical path, so this matches `pwd -P` without a
96
- // realpath call.
97
84
  const cwd = process.cwd();
98
85
  const cache = upstreamCacheRoot(ctx.root, env, cwd);
99
86
  const cacheParent = dirname(cache);
@@ -112,37 +99,41 @@ async function gatherPrepare(ctx) {
112
99
  throw prepareError("adapter returned an invalid preparation staging leaf");
113
100
  }
114
101
  const pluginRoot = location.destinationRoot;
115
- const additionalValidator = env.SUPERPOWERS_VALIDATOR || "";
116
102
  const executableValidator = env.SUPERPOWERS_VALIDATOR_EXECUTABLE || "";
117
103
  const tmpParent = dirname(pluginRoot);
104
+ const outcomes = [];
105
+ const failed = (message) => ({
106
+ kind: "failed",
107
+ outcomes,
108
+ validator: NO_VALIDATOR_OUTPUT,
109
+ message,
110
+ });
111
+ const selection = ctx.selection ?? (await computeEffectiveSelection(ctx.root, env));
112
+ let prefetch;
113
+ try {
114
+ prefetch = await ctx.adapter.validatePreparationBeforeFetch(adapterContext);
115
+ }
116
+ catch {
117
+ return {
118
+ outcome: failed(ctx.adapter.presentation.callFailure("prepare", adapterContext)
119
+ .unexpected),
120
+ cleanupWarning: null,
121
+ };
122
+ }
123
+ outcomes.push(prefetch.outcome);
124
+ if (!prefetch.outcome.ok)
125
+ return { outcome: failed(null), cleanupWarning: null };
126
+ if (prefetch.status !== 0) {
127
+ return {
128
+ outcome: failed(ctx.adapter.presentation.callFailure("prepare", adapterContext)
129
+ .invalidStatus),
130
+ cleanupWarning: null,
131
+ };
132
+ }
118
133
  await owned(`cannot create directory: ${tmpParent}`, () => mkdir(tmpParent, { recursive: true }));
119
134
  let cleanupWarning = null;
120
135
  const outcome = await withWorkspace(tmpParent, ".superpowers.prepare.", async (workspace) => {
121
- const outcomes = [];
122
- const failed = (message) => ({
123
- kind: "failed",
124
- outcomes,
125
- validator: NO_VALIDATOR_OUTPUT,
126
- message,
127
- });
128
136
  const candidate = join(workspace, location.stagingLeaf);
129
- const selection = ctx.selection ?? (await computeEffectiveSelection(ctx.root, env));
130
- let prefetch;
131
- try {
132
- prefetch =
133
- await ctx.adapter.validatePreparationBeforeFetch(adapterContext);
134
- }
135
- catch {
136
- return failed(ctx.adapter.presentation.callFailure("prepare", adapterContext)
137
- .unexpected);
138
- }
139
- outcomes.push(prefetch.outcome);
140
- if (!prefetch.outcome.ok)
141
- return failed(null);
142
- if (prefetch.status !== 0) {
143
- return failed(ctx.adapter.presentation.callFailure("prepare", adapterContext)
144
- .invalidStatus);
145
- }
146
137
  await owned(`cannot create directory: ${cacheParent}`, () => mkdir(cacheParent, { recursive: true }));
147
138
  if (selection.selectionMode === "pinned") {
148
139
  await fetchExactCommit(selection.effectiveSource, selection.desiredCommit, cache, workspace);
@@ -214,37 +205,7 @@ async function gatherPrepare(ctx) {
214
205
  return failed(prepared.outcome.result.compatibility.reason);
215
206
  }
216
207
  let validator = NO_VALIDATOR_OUTPUT;
217
- if (additionalValidator.length > 0) {
218
- // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/prepare:108::[ -f "$additional_validator` — `[ -f ]`.
219
- if (!(await regularFileExists(additionalValidator))) {
220
- return {
221
- kind: "failed",
222
- outcomes,
223
- validator,
224
- message: `additional plugin validator not found: ${additionalValidator}`,
225
- };
226
- }
227
- const ran = await runValidator(["python3", additionalValidator, candidate], UNBOUNDED_LEGACY, env, workspace);
228
- if (ran.kind === "launchFailed") {
229
- // PARITY, and it must stay a THROW. The legacy path rejects the workspace
230
- // callback on a spawn failure; withWorkspace returns the callback error
231
- // and the `outcomes` collected above are LOST. That loss is a recorded,
232
- // deliberately unassigned defect. Returning a failed outcome here would
233
- // replay those outcomes instead -- observably different control flow for
234
- // CLI-ENV-VALIDATOR-01, which this PR is not authorized to change.
235
- throw prepareError(`cannot execute additional plugin validator: ${additionalValidator}`, ran.cause);
236
- }
237
- validator = { stdout: ran.stdout.text, stderr: ran.stderr.text };
238
- if (ran.kind !== "exited" || ran.code !== 0) {
239
- return {
240
- kind: "failed",
241
- outcomes,
242
- validator,
243
- message: "additional plugin validation failed",
244
- };
245
- }
246
- }
247
- else if (executableValidator.length > 0) {
208
+ if (executableValidator.length > 0) {
248
209
  const resolution = await resolveValidator(executableValidator);
249
210
  const ran = await runValidator([executableValidator, candidate], BOUNDED_EXECUTABLE, env, workspace);
250
211
  // The disclosure lands BEFORE any early return, so a launch failure still
@@ -287,7 +248,7 @@ async function gatherPrepare(ctx) {
287
248
  // the workspace on return, and the candidate lives in it.
288
249
  //
289
250
  // atomicReplaceDir delegates to beginDirectoryPublication, whose outer
290
- // catch (`src/atomic.ts:341-348::if (cause`) wraps every non-SafetyError
251
+ // catch (`src/atomic.ts:319::if (cause`) wraps every non-SafetyError
291
252
  // into a SafetyError, so the callee owns every failure on this path and
292
253
  // re-emitting its own diagnostic is the sanctioned form of interpolation.
293
254
  // The hand-written prefix carries the live root, which the callee's message
@@ -334,61 +295,10 @@ async function performPrepare(argv, ctx) {
334
295
  run = await gatherPrepare(ctx);
335
296
  }
336
297
  catch (cause) {
337
- // Hand-written messages, per AGENTS.md's reader-diagnostics rule.
338
- // Reachable here: prepareError(), from this module's owned() wrappers,
339
- // its two manifest-version checks, asResolutionKind, and the
340
- // additional-validator branch's own throw on a legacy-validator launch
341
- // failure (its cause is the shared runner's (src/validator.ts) captured
342
- // spawn error, which oneLine never reads -- it takes .message only);
343
- // readManifest's three hookError messages
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
346
- // errno; and SafetyErrors from gitSafeSource, writeProvenance, and
347
- // withWorkspace.
348
- //
349
- // FOUR exceptions, all inherited and none a regression:
350
- // 1. resolveRef splices git's combined stdout+stderr into its own text
351
- // on the NON-PINNED path (src/upstream.ts), reached via
352
- // computeEffectiveSelection (src/effective-selection.ts).
353
- // This is the DEFAULT invocation -- plain `prepare`, `track-latest`,
354
- // and any non-40-hex SUPERPOWERS_REF -- not an exotic corner. Pinned
355
- // by
356
- // `tests/unit/upstream.test.ts:453-462::void test("resolveRef reports a query failure for latest`,
357
- // :471-481, and :483-501.
358
- // 2. fetchExactCommit splices the same combined stdout+stderr into its
359
- // own text on the PINNED path (both of its own splice sites in
360
- // src/upstream.ts, and proveCommit's, which it calls). This is the
361
- // rarer of the two raw-git-output paths, not the only one.
362
- // 3. `src/selection-store.ts:120-124::cause.module === "selection") {`
363
- // (same shape at :49, :86, :98) is the
364
- // module AGENTS.md's `src/selection-store.ts` bullet grandfathers:
365
- // it interpolates the caught error's own message, so Node errno
366
- // prose can reach this stream -- sanctioned, nothing here needs
367
- // fixing.
368
- // 4. Every runGit call site in this module (fetch, clone, checkout) can
369
- // reject instead of resolving: `src/git.ts:47-52::reject(new SafetyError` wraps every string
370
- // errno other than ENOENT in
371
- // `new SafetyError("git", \`cannot run git: ${failure.message}\`)`,
372
- // and that Node spawn-level message reaches ctx.stderr through this
373
- // catch. So the claim that the non-pinned clone/fetch/checkout
374
- // branch names only the source is true for a non-zero *exit status*
375
- // (handled explicitly below, in gatherPrepare) but false for a
376
- // *spawn-level* failure -- runGit throws rather than returning a
377
- // status in that case, and this outer catch is what stands between
378
- // it and the stream.
379
- //
380
- // oneLine() at this catch collapses each of exceptions 1, 2, and 4 -- the
381
- // three carrying git-derived text -- to a single line. It collapses CR/LF
382
- // only, so it bounds how much of that text lands, not what it may
383
- // contain.
384
- //
385
- // runAdapter's closing `throw cause` (src/harnesses/codex/adapter.ts) does NOT arrive
386
- // here -- the call site catches it and converts it to a hand-written
387
- // message per AGENTS.md's reader-diagnostics rule.
388
- //
389
- // gatherPrepare performs no writes of its own, so this catch cannot also be
390
- // reached by an EPIPE from prepare's own output: every write below runs
391
- // only after this try/catch has resolved.
298
+ // Reader wrappers supply their own diagnostics. The saved-selection read
299
+ // path retains its sanctioned interpolation at
300
+ // `src/selection-store.ts:116-121::if (cause instanceof SafetyError && cause.module === "selection") {`.
301
+ // oneLine() bounds any inherited git or filesystem diagnostic to one line.
392
302
  ctx.stderr.write(`error: ${oneLine(cause)}\n`);
393
303
  return 1;
394
304
  }
@@ -418,7 +328,7 @@ async function performPrepare(argv, ctx) {
418
328
  // completed before cleanup ran, so it is not being reported as unverified
419
329
  // -- but something did still go wrong, and AGENTS.md's fail-closed rule
420
330
  // extends to it. Mirrors
421
- // `src/commands/install.ts:562-570::if (cleanupWarning`.
331
+ // `src/commands/install.ts:446::if (cleanupWarning`.
422
332
  ctx.stderr.write(`error: ${cleanupWarning}\n`);
423
333
  return 1;
424
334
  }
@@ -1,23 +1,11 @@
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 { callAdapter } from "./adapter-call.js";
4
5
  export const PROBE_USAGE = "error: usage: superpowers-manager probe [--porcelain]\n";
5
- // Ports scripts/core/validate-adapter-response.py's replay (:235-238) and its
6
- // error/hint block (:269-272). The shell ran that validator on EVERY adapter
7
- // response, so adapter messages reached the operator on their declared streams
8
- // in array order, and a controlled failure printed `error:` plus one `hint:`
9
- // per hint. DIAG-ADAPTER-01 retains that contract
10
- // (docs/baseline/protocol-disposition.md); dropping it here would be a
11
- // silent diagnostics regression, not a simplification.
12
- //
13
- // Interpolating error.message and each hint is the sanctioned form (AGENTS.md):
14
- // src/harnesses/codex/adapter.ts has 52 `fail()` sites and none interpolates a caught error's
15
- // message, so the callee owns every failure reachable on this path. Those two
16
- // writes now live in writeAdapterFailure (src/adapter-result.ts), which
17
- // validates all three strings before the first of them reaches the stream.
18
- //
19
- // This writes to ctx, so it MUST NOT be called from inside gatherProbe's try
20
- // -- see the ProbeOutcome note below.
6
+ // Replay adapter messages in order on their declared streams. Adapter failures
7
+ // are emitted only after terminal-safety validation. This writes to ctx, so it
8
+ // must run after gathering, outside a gather function's failure handling.
21
9
  export function replayOutcome(outcome, ctx) {
22
10
  // Hoisted ABOVE the message loop, and that ordering is the point: a failure
23
11
  // whose code, message, or a hint carries a terminal control character must
@@ -33,153 +21,14 @@ export function replayOutcome(outcome, ctx) {
33
21
  // stored, so scanning it here would re-check a population that cannot fail.
34
22
  writeAdapterFailure(ctx, outcome);
35
23
  }
36
- function record(value) {
37
- return value !== null && typeof value === "object"
38
- ? value
39
- : null;
24
+ function coherentControl(value) {
25
+ return (value.recoveryState !== "required" ||
26
+ (value.probeEligibility.kind === "blocked" &&
27
+ value.mutationEligibility.kind === "blocked"));
40
28
  }
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
- }
116
- // `runAdapter` reports a CONTROLLED failure by RETURN VALUE, not by throwing
117
- // (`src/adapter-result.ts:32-35::export interface AdapterResult`). The shell got
118
- // fail-closed behaviour for free: spw_invoke_adapter returned 1 and
119
- // scripts/probe ran under `set -eu`.
120
- // Omitting the status check here would read a failed inspection as absent
121
- // evidence and report it as success.
122
- //
123
- // It does still THROW for a non-AdapterFailure cause (runAdapter's closing
124
- // `throw cause`, src/harnesses/codex/adapter.ts). That is caught here rather than in runProbe's
125
- // outer catch, because the two need different diagnostics -- see spec §3.3a.
126
- //
127
- // Reached through ctx.adapter, not a direct module-level dependency on the
128
- // adapter module: src/commands/prepare.ts and this module are the two the
129
- // injected double must observe, because install reaches the adapter through
130
- // gatherProbe and runPrepare. Spec §4.5.
131
- async function inspect(call, unexpected, invalidStatus, valid) {
132
- let result;
133
- try {
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 };
141
- }
142
- catch {
143
- return { ok: false, result: null, message: unexpected };
144
- }
145
- const outcome = result.outcome;
146
- if (result.status !== 0 || !outcome.ok) {
147
- // The `outcome.ok && status !== 0` combination cannot arise from
148
- // successResult/failureResult, so it gets its own hand-written message
149
- // rather than falling through to a replay that would print nothing.
150
- return {
151
- ok: false,
152
- result,
153
- message: outcome.ok ? invalidStatus : null,
154
- };
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
- }
164
- return { ok: true, result: { status: result.status, outcome } };
165
- }
166
- // Runs every step that can throw or fail closed, returning the outcome as
167
- // data. It performs no writes of its own, so nothing inside runProbe's try can
168
- // raise EPIPE — the same shape as src/commands/unpin.ts's attemptUnpin.
169
- //
170
- // The outcomes are CARRIED OUT rather than replayed in place, for that same
171
- // reason: replayOutcome writes to ctx, and a write inside this try could
172
- // raise EPIPE and be caught and relabelled as a selection failure. runProbe
173
- // replays them, in collection order, after the try/catch has resolved. The
174
- // operator-visible result is identical -- probe emits nothing else until the
175
- // very end -- and the EPIPE hazard never exists.
176
- // Exported for src/commands/install.ts and src/commands/update.ts, which need
177
- // probe's FACTS rather than its rendering. The historical
178
- // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/lifecycle.sh:39-41::spw_probe_field`
179
- // implementation awk-parsed the porcelain back into fields; that round trip is
180
- // gone.
29
+ // Collect outcomes without writing so output failures cannot be classified as
30
+ // inspection failures. Replay collected outcomes after gathering completes.
181
31
  export async function gatherProbe(ctx) {
182
- // Order mirrors `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/probe:24-40::spw_compute_effective_selection` exactly.
183
32
  const selection = ctx.selection ?? (await computeEffectiveSelection(ctx.root, ctx.env));
184
33
  const outcomes = [];
185
34
  const adapterContext = { root: ctx.root, env: ctx.env };
@@ -196,19 +45,28 @@ export async function gatherProbe(ctx) {
196
45
  const before = resources === null
197
46
  ? []
198
47
  : await ctx.coordination.observeResources(resources);
199
- const collect = async (call, unexpected, invalidStatus, valid, replaySuccess = true) => {
200
- const result = await inspect(call, unexpected, invalidStatus, valid);
48
+ const collect = async (call, unexpected, invalidStatus, acceptValue, replaySuccess = true) => {
49
+ let result = await callAdapter(call, { unexpected, invalidStatus });
50
+ if (result.ok && acceptValue !== undefined) {
51
+ try {
52
+ if (!acceptValue(result.result.outcome.result))
53
+ result = { ok: false, result: result.result, message: invalidStatus };
54
+ }
55
+ catch {
56
+ result = { ok: false, result: result.result, message: invalidStatus };
57
+ }
58
+ }
201
59
  if (result.result !== null && (replaySuccess || !result.result.outcome.ok))
202
60
  outcomes.push(result.result.outcome);
203
61
  return result;
204
62
  };
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);
63
+ const prepared = await collect(() => ctx.adapter.inspectPrepared(selection, adapterContext), "cannot inspect prepared harness state", "adapter reported a failure status for prepared harness inspection");
206
64
  if (!prepared.ok) {
207
65
  return { status: 1, outcomes, message: prepared.message };
208
66
  }
209
67
  const failure = (site) => ctx.adapter.presentation.callFailure(site, adapterContext);
210
68
  const installedFailure = failure("probe-installed");
211
- const installed = await collect(() => ctx.adapter.inspectInstalled(selection, adapterContext), installedFailure.unexpected, installedFailure.invalidStatus, validInstalled);
69
+ const installed = await collect(() => ctx.adapter.inspectInstalled(selection, adapterContext), installedFailure.unexpected, installedFailure.invalidStatus);
212
70
  if (!installed.ok) {
213
71
  return { status: 1, outcomes, message: installed.message };
214
72
  }
@@ -219,22 +77,22 @@ export async function gatherProbe(ctx) {
219
77
  message: "cannot determine harness observation resources",
220
78
  };
221
79
  const ownershipFailure = failure("probe-ownership");
222
- const ownership = await collect(() => ctx.adapter.inspectOwnership(adapterContext), ownershipFailure.unexpected, ownershipFailure.invalidStatus, validOwnership);
80
+ const ownership = await collect(() => ctx.adapter.inspectOwnership(adapterContext), ownershipFailure.unexpected, ownershipFailure.invalidStatus);
223
81
  if (!ownership.ok) {
224
82
  return { status: 1, outcomes, message: ownership.message };
225
83
  }
226
84
  const controlFailure = failure("probe-control");
227
- const control = await collect(() => ctx.adapter.inspectUpdateControl(adapterContext), controlFailure.unexpected, controlFailure.invalidStatus, validControl);
85
+ const control = await collect(() => ctx.adapter.inspectUpdateControl(adapterContext), controlFailure.unexpected, controlFailure.invalidStatus, coherentControl);
228
86
  if (!control.ok) {
229
87
  return { status: 1, outcomes, message: control.message };
230
88
  }
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);
89
+ const preparedAfter = await collect(() => ctx.adapter.inspectPrepared(selection, adapterContext), "cannot inspect prepared harness state", "adapter reported a failure status for prepared harness inspection", undefined, false);
232
90
  if (!preparedAfter.ok)
233
91
  return { status: 1, outcomes, message: preparedAfter.message };
234
- const installedAfter = await collect(() => ctx.adapter.inspectInstalled(selection, adapterContext), installedFailure.unexpected, installedFailure.invalidStatus, validInstalled, false);
92
+ const installedAfter = await collect(() => ctx.adapter.inspectInstalled(selection, adapterContext), installedFailure.unexpected, installedFailure.invalidStatus, undefined, false);
235
93
  if (!installedAfter.ok)
236
94
  return { status: 1, outcomes, message: installedAfter.message };
237
- const controlAfter = await collect(() => ctx.adapter.inspectUpdateControl(adapterContext), controlFailure.unexpected, controlFailure.invalidStatus, validControl, false);
95
+ const controlAfter = await collect(() => ctx.adapter.inspectUpdateControl(adapterContext), controlFailure.unexpected, controlFailure.invalidStatus, coherentControl, false);
238
96
  if (!controlAfter.ok)
239
97
  return { status: 1, outcomes, message: controlAfter.message };
240
98
  const after = await ctx.coordination.observeResources(resources);
@@ -270,7 +128,6 @@ export async function gatherProbe(ctx) {
270
128
  ownership: ownership.result.outcome.result,
271
129
  control: control.result.outcome.result,
272
130
  compatibility: preparedState.compatibility,
273
- resources: after,
274
131
  resourceState: control.result.outcome.result.recoveryState === "required"
275
132
  ? "recovery-required"
276
133
  : after.some((observation) => observation.state === "owned")
@@ -285,17 +142,8 @@ export async function gatherProbe(ctx) {
285
142
  };
286
143
  }
287
144
  export async function runProbe(argv, ctx) {
288
- // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/probe:42::porcelain` tested only `[ "${1:-}" = "--porcelain" ]`, so a typo'd
289
- // flag silently produced human output. Rejecting it is a deliberate
290
- // narrowing, recorded as a port-only entry in
291
- // tests/migration-inventory/probe.md.
292
- //
293
- // This guard is NOT the production path. src/cli.ts's parseArgs rejects the
294
- // same inputs first, before preflight, with the usage block the CLI's other
295
- // usage errors carry — exactly the arrangement track-latest and unpin have,
296
- // where the in-module check is an unreachable-from-CLI duplicate. It stays
297
- // because runProbe is also called directly by tests and by any future
298
- // in-process caller that has not been through parseArgs.
145
+ // The CLI parser rejects these inputs first. Keep this guard for direct
146
+ // in-process callers, which do not necessarily pass through that parser.
299
147
  const porcelain = argv.length === 1 && argv[0] === "--porcelain";
300
148
  if (!porcelain && argv.length !== 0) {
301
149
  ctx.stderr.write(PROBE_USAGE);
@@ -306,91 +154,14 @@ export async function runProbe(argv, ctx) {
306
154
  outcome = await gatherProbe(ctx);
307
155
  }
308
156
  catch (cause) {
309
- // Hand-written messages, per AGENTS.md's reader-diagnostics rule. Reachable
310
- // here: the selectionErrors validateSource raises
311
- // (`src/selection.ts:137::malformed`, :140, requireSingleLineString at :77),
312
- // reached from
313
- // computeEffectiveSelection's validateSource call
314
- // (src/effective-selection.ts); the ones validateRecord raises
315
- // (`src/selection.ts:198::integer`, :219, requireObject at :47, requireExactKeys at
316
- // :64, validatePinnedRecord at :170-183), reached from
317
- // `src/selection-store.ts:103::return validateRecord` on the read path; the
318
- // selectionErrors requireAbsolute and selectionConfigDir
319
- // (src/effective-selection.ts) raise
320
- // for a non-absolute or missing config directory; readConfigRef's `cannot
321
- // read packaged upstream ref <path>` (src/upstream.ts); and resolveRef's own
322
- // two no-match diagnostics, `no stable semver tag found for latest-release`
323
- // and `cannot resolve upstream ref: <ref>` (src/upstream.ts). normalizeSaved
324
- // (`src/selection.ts:222-241::normalizeSaved`) throws nothing at all.
325
- //
326
- // THREE exceptions, all inherited and none a regression:
327
- // 1. resolveRef splices git's combined stdout+stderr into its own text
328
- // (its three `cannot query upstream ...` throws, src/upstream.ts),
329
- // reached via computeEffectiveSelection's resolveRef call
330
- // (src/effective-selection.ts). The packaged ref is `latest-release`,
331
- // which resolveRef answers with an ls-remote, so an ordinary unpinned
332
- // run reaches this rather than only an exotic corner -- though not
333
- // every unpinned run does: resolveRef's COMMIT_INPUT_RE branch returns
334
- // before any runGit call, so a 40-hex requested ref splices nothing.
335
- // Pinned by
336
- // `tests/unit/upstream.test.ts:453::void test("resolveRef reports a query failure for latest`,
337
- // :471, and :483.
338
- // 2. The
339
- // `src/selection-store.ts:120-124::cause.module === "selection") {`
340
- // site (same shape at :49, :86, :98) is the module AGENTS.md's
341
- // `src/selection-store.ts` bullet grandfathers:
342
- // it interpolates the caught error's own message, so Node errno
343
- // prose can reach this stream. Reached on the READ path only, via
344
- // loadSavedSelection (src/effective-selection.ts) ->
345
- // readSelectionState
346
- // (`src/selection-store.ts:149::export async function readSelectionState`).
347
- // This module's four write-only interpolating sites -- ensureStateDirectory
348
- // (:172), finalStateDiagnostic (:197), and the two in
349
- // writeSelectionState's own catch (:225, :231) -- are all
350
- // unreachable from probe, which never writes.
351
- // 3. Every runGit call site inside resolveRef (src/upstream.ts) can
352
- // reject instead of resolving. On the non-ENOENT arm of
353
- // `src/git.ts:47-52::if (typeof`, runGit builds the message
354
- // "cannot run git: " followed by the Node spawn error's own message
355
- // (:51) and rejects with a SafetyError carrying it (:52), so that
356
- // Node spawn-level text reaches ctx.stderr through this catch. The
357
- // ENOENT arm (:50) is hand-written and carries nothing. A non-zero
358
- // *exit status* is handled by exception 1 above; this is the
359
- // *spawn-level* case, where runGit throws rather than returning a
360
- // status.
361
- //
362
- // oneLine() at this catch collapses each of exceptions 1 and 3 -- the two
363
- // carrying git-derived text -- to a single line. It collapses CR/LF only,
364
- // so it bounds how much of that text lands, not what it may contain.
365
- //
366
- // fetchExactCommit is deliberately NOT in this list, unlike prepare's
367
- // fetchExactCommit exception, which runPrepare's catch block documents.
368
- // Its only callers are `src/upstream-cli.ts:83::await fetchExactCommit` and
369
- // gatherPrepare's own call in src/commands/prepare.ts (git grep -n
370
- // fetchExactCommit -- src/ is the check that keeps "only" true), so probe
371
- // never reaches it and
372
- // its splice sites cannot appear on this stream. Do not add it back by
373
- // symmetry with prepare.
374
- //
375
- // generatedCommitOrEmpty is not in this list either: it cannot throw at
376
- // all. It (src/provenance.ts) delegates to readGeneratedCommitLenient,
377
- // which catches every failure and returns "".
378
- //
379
- // A non-AdapterFailure re-thrown by runAdapter's closing `throw cause`
380
- // (src/harnesses/codex/adapter.ts) does NOT reach here: inspect() catches it and converts
381
- // it to a hand-written message per AGENTS.md's reader-diagnostics rule -- a
382
- // rethrown cause is exactly the failure src/harnesses/codex/adapter.ts declined to own, so
383
- // its text must never reach this stream. See §3.3a.
384
- //
385
- // gatherProbe performs no writes of its own, so this catch cannot also be
386
- // reached by an EPIPE from probe's own output — every write below runs
387
- // only after this try/catch has resolved.
157
+ // Reader wrappers supply their own diagnostics. The saved-selection read
158
+ // path retains its sanctioned interpolation at
159
+ // `src/selection-store.ts:116-121::if (cause instanceof SafetyError && cause.module === "selection") {`.
160
+ // oneLine() bounds any inherited git or filesystem diagnostic to one line.
388
161
  ctx.stderr.write(`error: ${oneLine(cause)}\n`);
389
162
  return 1;
390
163
  }
391
- // Replay first, on both paths: the shell validator replayed every response's
392
- // messages whether or not that response was a failure at
393
- // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/validate-adapter-response.py:268::replay(messages)`.
164
+ // Replay before reporting a command-level result on either path.
394
165
  for (const each of outcome.outcomes)
395
166
  replayOutcome(each, ctx);
396
167
  if (outcome.status === 1) {
@@ -15,7 +15,7 @@ export async function runTrackLatest(argv, ctx) {
15
15
  // writing, under "Invalid existing state must block overwrite" —
16
16
  // readSelectionState's own readOpenedRecord/parseRecordBytes is what
17
17
  // validates an existing record, via validateRecord at
18
- // `src/selection-store.ts:103::return validateRecord`). Calling loadSavedSelection here preserves
18
+ // `src/selection-store.ts:99::return validateRecord`). Calling loadSavedSelection here preserves
19
19
  // `git show 349fe2ed405b371ec2de1347bb3fc50c6bc15dc4:scripts/track-latest:20-21::read`'s read-then-write shape, so this command
20
20
  // stays fail-closed on its own terms rather than solely by depending on
21
21
  // the store's internals.