@nextcommerce/campaigns-os 1.37.3 → 1.41.2

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 (49) hide show
  1. package/AGENTS.md +114 -10
  2. package/CHANGELOG.md +530 -0
  3. package/README.md +38 -27
  4. package/contracts/agent-relevant-change-policy.v1.json +11 -1
  5. package/contracts/effects.v1.json +4794 -0
  6. package/contracts/release-ledger.json +789 -0
  7. package/contracts/supported-surface.json +25 -5
  8. package/docs/build-packet.md +27 -16
  9. package/docs/demo-preview.md +1 -1
  10. package/docs/diagnostics.md +7 -4
  11. package/docs/effects.md +281 -0
  12. package/docs/gateway-login.md +113 -0
  13. package/docs/orientation-contract-reference.md +4 -1
  14. package/docs/progress-snapshots.md +3 -3
  15. package/docs/qa-and-test-orders.md +3 -3
  16. package/docs/readback.md +523 -0
  17. package/docs/runtime-readiness.md +1 -1
  18. package/docs/sdk-storage-compatibility.md +1 -1
  19. package/docs/skills-revision.md +364 -0
  20. package/docs/supported-surface.md +11 -3
  21. package/docs/versioning.md +8 -4
  22. package/package.json +8 -3
  23. package/schemas/campaign-runtime-build-packet.v0.schema.json +5 -0
  24. package/schemas/campaigns-os-effects.v1.schema.json +211 -0
  25. package/schemas/campaigns-os-readback.v2.schema.json +267 -0
  26. package/skills/campaign-lifecycle-orientation/SKILL.md +174 -0
  27. package/skills/campaign-readback-classification/SKILL.md +230 -0
  28. package/skills/campaign-run-evidence/SKILL.md +140 -0
  29. package/skills/contribution-intake/SKILL.md +85 -0
  30. package/skills/next-campaigns-build/SKILL.md +33 -12
  31. package/skills/next-campaigns-os/SKILL.md +45 -21
  32. package/skills/next-campaigns-os-setup/SKILL.md +35 -14
  33. package/skills/next-campaigns-polish/SKILL.md +43 -17
  34. package/skills/next-campaigns-qa/SKILL.md +48 -24
  35. package/skills.json +39 -6
  36. package/src/admin-transport.mjs +123 -0
  37. package/src/cli.mjs +991 -200
  38. package/src/credential-store.mjs +183 -0
  39. package/src/deviation.mjs +3 -2
  40. package/src/diagnostic.mjs +4 -1
  41. package/src/gate-actions.mjs +2 -2
  42. package/src/install-mode.mjs +17 -9
  43. package/src/lifecycle.mjs +95 -0
  44. package/src/login.mjs +152 -0
  45. package/src/package-install-fixture.mjs +3 -2
  46. package/src/qa-node.mjs +56 -19
  47. package/src/qa-publish.mjs +108 -2
  48. package/src/readback.mjs +1936 -0
  49. package/src/remit.mjs +17 -3
package/src/cli.mjs CHANGED
@@ -29,6 +29,9 @@ import { describeSdkIgnoredMetaTags, isSdkIgnoredMetaTag } from "./sdk-meta-tags
29
29
  import { HIDDEN_EAGER_MEDIA_ACTIONS, requiredActionText, substitutePacket } from "./gate-actions.mjs";
30
30
  import { ORDER_PATH_DEPTH_DRIFT_CODE, orderPathDepthDriftText, orderPathDepthReconcileAction, orderPathDepthsDisagree, parseOrderPathDepthFlag } from "./proof-policy.mjs";
31
31
  import { specMaterialHash } from "./spec-identity.mjs";
32
+ // The same predicate stage-ledger.mjs judges a mutator's result with, imported
33
+ // rather than re-stated so the waive preview and the commit agree by identity.
34
+ import { isPlainObject } from "./repo-scan.mjs";
32
35
  import { anyAssemblyReportStageBlocked, applyDerivedAssemblyReportSummary, commitAssemblyReport, QA_GATE_PLACEHOLDER_TEXT_RESIDUE, qaGatePassedForCurrentBuild, recordProducerStageOutcome } from "./stage-ledger.mjs";
33
36
  import { SESSION_ENDING_DISPOSITIONS, summarizePlaceholderTextGate, summarizePurchaseProof } from "./qa-verdict.mjs";
34
37
  import { assessRunRecordCloseout, identityMatches, latestMatchingRunRecord, reasonIsRemitRecovery } from "./run-record-closeout.mjs";
@@ -96,13 +99,18 @@ import { canonicalPath, sameFile } from "./fs-identity.mjs";
96
99
  import { DEFAULT_PROXY_BASE, fetchSpecByMapId } from "./spec-fetch.mjs";
97
100
  import { writeMapSdkPin } from "./map-pin-writeback.mjs";
98
101
  import { discoverQaVerdicts, iterateQaVerdicts, qaVerdictCandidateScore, qaVerdictCandidateTime, qaVerdictPathHints } from "./qa-verdict-discovery.mjs";
99
- import { assertSecureProxyBase, boundedResponseText, DEFAULT_RUNS_ENDPOINT, describeRemitBaseKind, isLoopbackHostname, REMIT_RESULTS, remitRunRecord } from "./remit.mjs";
102
+ import { assertFetchAvailable, assertSecureProxyBase, boundedResponseText, DEFAULT_RUNS_ENDPOINT, describeRemitBaseKind, isLoopbackHostname, REMIT_RESULTS, remitRunRecord } from "./remit.mjs";
100
103
  import {
101
104
  aggregateLifecycleForRun,
102
105
  appendLifecycleEntry,
103
106
  LIFECYCLE_JOURNAL_REL_PATH,
104
107
  NOOP_RECORDER,
105
108
  readLifecycleJournal,
109
+ REFUSED_INVOCATION,
110
+ refusalSeen,
111
+ refused,
112
+ refusing,
113
+ runWithRefusalScope,
106
114
  withCommandLifecycle,
107
115
  } from "./lifecycle.mjs";
108
116
  import {
@@ -139,7 +147,7 @@ import {
139
147
  formatStandardizationReportMarkdown,
140
148
  } from "./standardization-report.mjs";
141
149
  import { singleLineDetail, singleLineField } from "./text-safety.mjs";
142
- import { derivePackagePin, invocationPrefixFor, localInstallStatus, resolveInvocation } from "./install-mode.mjs";
150
+ import { derivePackagePin, invocationPrefixFor, LOCAL_INVOCATION_PREFIX, localInstallStatus, resolveInvocation } from "./install-mode.mjs";
143
151
  import {
144
152
  campaignRouteRoot,
145
153
  isAbsoluteHttpUrl,
@@ -292,7 +300,6 @@ import {
292
300
  } from "./spec-derive.mjs";
293
301
  import {
294
302
  adminApiBaseForStore,
295
- defaultStoreTokenEnvVar,
296
303
  normalizeStoreSubdomain,
297
304
  parseStoreTokenSource,
298
305
  planStoreProfileDerive,
@@ -316,10 +323,11 @@ export { derivePackagePin, localInstallStatus };
316
323
 
317
324
  // Every command this CLI PRODUCES for an operator or agent to copy is spelled
318
325
  // once, here, for the install it runs from (see install-mode.mjs): bare
319
- // `campaigns-os` from a checkout, `npx campaigns-os` from a campaign folder
320
- // that pins the toolkit, `npx --yes <spec>` from an npx cache. Result payloads
321
- // are never rewritten after the fact — a path, a quoted argument or a data
322
- // value that happens to contain the words is left exactly as it is.
326
+ // `campaigns-os` from a checkout, `npx --no-install campaigns-os` from a
327
+ // campaign folder that pins the toolkit, `npx --yes <spec>` from an npx cache.
328
+ // Result payloads are never rewritten after the fact — a path, a quoted
329
+ // argument or a data value that happens to contain the words is left exactly
330
+ // as it is.
323
331
  function cmd(verb, rest = "") {
324
332
  const prefix = invocationPrefixFor(ROOT);
325
333
  return `${prefix} ${verb}${rest ? ` ${rest}` : ""}`;
@@ -467,15 +475,19 @@ Usage:
467
475
  campaigns-os standardize --target <campaign-repo> [--family <family>] [--slug <slug>] [--sdk-support-policy <path.json>] [--field-contract <path.json>] [--no-doctor] [--json]
468
476
  campaigns-os theme inspect --packet <campaign-runtime.build.json> [--context <json>] [--theme-policy <inspect_only|auto|off>] [--json]
469
477
  campaigns-os theme generate --packet <campaign-runtime.build.json> [--context <json>] [--out-dir <dir>] [--force] [--json]
470
- campaigns-os theme waive --packet <campaign-runtime.build.json> --reason "<why>" --waived-by "<named human>" [--expires-at <ISO>] [--report <json>] [--json] # record an explicit theme-gate waiver on the assembly report; placeholders such as "operator" are refused
471
- campaigns-os checkpoint waive --packet <campaign-runtime.build.json> --gate <checkpoint-id> --reason "<why>" --waived-by "<named human>" [--expires-at <ISO>] [--review-condition "<trigger>"] [--report <json>] [--json] # one bound is required; registered gates: page_kit.store_profile, page_kit.sdk_version, polish.hidden_eager_media, built_output.upsell_selector_scope
478
+ campaigns-os theme waive --packet <campaign-runtime.build.json> --reason "<why>" --waived-by "<named human>" [--expires-at <ISO>] [--report <json>] [--dry-run] [--json] # record an explicit theme-gate waiver on the assembly report; placeholders such as "operator" are refused. --dry-run validates the same way and prints the waiver it would write, without touching the report
479
+ campaigns-os checkpoint waive --packet <campaign-runtime.build.json> --gate <checkpoint-id> --reason "<why>" --waived-by "<named human>" [--expires-at <ISO>] [--review-condition "<trigger>"] [--report <json>] [--dry-run] [--json] # one bound is required; registered gates: page_kit.store_profile, page_kit.sdk_version, polish.hidden_eager_media, built_output.upsell_selector_scope. --dry-run runs every check (named human, bounds, registered and waivable gate) and prints the waiver it would write, without touching the report
472
480
  campaigns-os page-kit sync --packet <campaign-runtime.build.json> [--dry-run] [--json] # write the CampaignSpec's Store Profile fields (campaign.store_*) and SDK pin (global_config.sdk_version, runtime.sdk_version alias) into the target's _data/campaigns.json entry for the packet's route, printing a field-by-field diff; the recovery for a doctor blocked on page_kit.store_profile / page_kit.sdk_version after a fresh scaffold. Writes only those ten fields, only from usable spec values (a bad pin, a non-http URL, a non-tel: phone URI or the demo value itself is reported as not synced, status PARTIAL); exit 2 when the entry or the spec is missing, or the spec identifies another campaign.
473
- campaigns-os spec derive --packet <campaign-runtime.build.json> [--dry-run] [--json] [--report <json>] [--from-store <subdomain> [--store-token-source env:<VAR>]] [--write-map] [--proxy-base <url>] # write the fields the target repo already states into the packet's local CampaignSpec (spec.local_path): the SDK pin from _data/campaigns.json[<route>].sdk_version (global_config.sdk_version, and the runtime.sdk_version alias when declared), each page's page_url from the page tree under src/<route>/ (filename or permalink), and the analytics ids the entry carries (gtm_id -> analytics.providers.gtm.containerId, fb_pixel_id -> analytics.providers.facebook.pixelId); prints a field-by-field before -> after diff and writes nothing else. Repo-derived fields only and no network by default; --from-store <subdomain> (the <store> of <store>.29next.store) also reads the store's Admin API with the token in env:<SUBDOMAIN>_ADMIN_TOKEN (or --store-token-source env:<VAR>; a token never goes on the command line) and writes the nine campaign.store_* Store Profile fields: store_name and store_url (primary domain) and store_phone/store_phone_tel from GET /store/, and store_terms/privacy/contact/returns/shipping as https://<primary domain>/<slug>/ from the one storefront page (GET /pages/) whose slug or title names each policy; an empty store field, no page or several never empties the spec's value. A field the repo or store cannot state (a scaffold's seeded pin, an unbound page, an empty or malformed id, an active page_kit.sdk_version waiver, an empty store field, an unbound policy page) is reported as not derived, status PARTIAL; exit 2 when the packet, the spec or the target entry is missing, the spec identifies another campaign, or the store cannot be read (credential missing, 401/403, no such store, unreachable). --write-map also records the derived pin into the saved Map's Build hints (Campaign Cart SDK version) through the proxy Worker (PUT /api/maps/<spec.map_id> under X-Campaign-Key, the packet's Campaigns API key, with the Map's spec_hash as the X-Spec-Hash precondition): written when the Map declares no pin or one behind the repo, unchanged when equal, refused (warning, exit 0) when the Map pin is ahead or cannot be ordered, failed (error, exit 2) when the key is missing or mismatched, the Map is gone, was saved in between, or the proxy refuses the body; the write is recorded on the Assembly Report evidence[] and in the result's map object. --proxy-base overrides the canonical proxy (https, or a loopback host over http); --dry-run reads the Map and reports would_write without a PUT.
481
+ campaigns-os spec derive --packet <campaign-runtime.build.json> [--dry-run] [--json] [--report <json>] [--from-store <subdomain> [--store-token-source env:<VAR>]] [--write-map] [--proxy-base <url>] # write the fields the target repo already states into the packet's local CampaignSpec (spec.local_path): the SDK pin from _data/campaigns.json[<route>].sdk_version (global_config.sdk_version, and the runtime.sdk_version alias when declared), each page's page_url from the page tree under src/<route>/ (filename or permalink), and the analytics ids the entry carries (gtm_id -> analytics.providers.gtm.containerId, fb_pixel_id -> analytics.providers.facebook.pixelId); prints a field-by-field before -> after diff and writes nothing else. Repo-derived fields only and no network by default; --from-store <subdomain> (the <store> of <store>.29next.store) also reads through campaigns-os login gateway credentials (--store-token-source env:<VAR> explicitly selects the warned break-glass Admin path; a token never goes on the command line) and writes the nine campaign.store_* Store Profile fields: store_name and store_url (primary domain) and store_phone/store_phone_tel from GET /store/, and store_terms/privacy/contact/returns/shipping as https://<primary domain>/<slug>/ from the one storefront page (GET /pages/) whose slug or title names each policy; an empty store field, no page or several never empties the spec's value. A field the repo or store cannot state (a scaffold's seeded pin, an unbound page, an empty or malformed id, an active page_kit.sdk_version waiver, an empty store field, an unbound policy page) is reported as not derived, status PARTIAL; exit 2 when the packet, the spec or the target entry is missing, the spec identifies another campaign, or the store cannot be read (credential missing, 401/403, no such store, unreachable). --write-map also records the derived pin into the saved Map's Build hints (Campaign Cart SDK version) through the proxy Worker (PUT /api/maps/<spec.map_id> under X-Campaign-Key, the packet's Campaigns API key, with the Map's spec_hash as the X-Spec-Hash precondition): written when the Map declares no pin or one behind the repo, unchanged when equal, refused (warning, exit 0) when the Map pin is ahead or cannot be ordered, failed (error, exit 2) when the key is missing or mismatched, the Map is gone, was saved in between, or the proxy refuses the body; the write is recorded on the Assembly Report evidence[] and in the result's map object. --proxy-base overrides the canonical proxy (https, or a loopback host over http); --dry-run reads the Map and reports would_write without a PUT.
474
482
  campaigns-os page-kit parity --packet <campaign-runtime.build.json> [--report <json>] [--json] # local proof mode (deploy.target local-serve): render the current source in development and production through the target's page-kit into temp dirs, assert the served _site/ is the current development render and that production differs from it only in environment-gated output (same page set, same route slugs, same Campaign Cart pin and next-api-key); records stages.assembly.evidence.local_proof.production_parity, which doctor reads as local_proof.production_parity. Exit 2 on a non-gated difference.
475
483
  campaigns-os polish capture --packet <campaign-runtime.build.json> --base-url <url> [--report <json>] [--headed] [--auth-cookie <cookie>] [--json]
484
+ campaigns-os readback <target-repo-root> [--json] [--packet <path>] [--doctor <path>] [--context <path>] [--report <path>] [--qa-verdict <path>] [--findings <path>] # read-only projection of one run's emitted artifacts (packet, doctor output, build context, assembly report, QA verdict, findings export): artifact states, per-artifact freshness against the checkout's HEAD reflog, doctor warning grouping, skip cascades and cross-artifact divergences. Writes nothing, starts no process, touches no network, and records no lifecycle entry; --json emits one campaigns-os-readback/v2 object (docs/readback.md). Exit 2 for a missing target root or a Build Packet set freshness cannot single out.
485
+ campaigns-os readback --example [--json] # project the bundled synthetic sample; freshness is not computable for it by design
476
486
  campaigns-os validate-assembly-report --report <json> [--json]
477
487
  campaigns-os install-skills [--platform <claude|codex|agents|all>] [--target <skills-dir>] [--dry-run] [--json]
478
- campaigns-os tooling status [--platform <claude|codex|agents|all>] [--target <skills-dir>] [--json] # install-mode (checkout or pinned package), git, and skill freshness preflight
488
+ campaigns-os login [--store <subdomain>]
489
+ campaigns-os logout [--store <subdomain>]
490
+ campaigns-os tooling status [--platform <claude|codex|agents|all>] [--target <skills-dir>] [--skills-revision <bundle-revision|skill-id@version>] [--packet <campaign-runtime.build.json>] [--force] [--json] # install-mode, git, skill freshness, and local gateway login/store/expiry/reported version. --skills-revision checks the bundle revision the skill you loaded states on its first body line (or that one skill's <skill-id>@<version>) against the bundle THIS CLI ships: revision_check is match, mismatch or unchecked, and a mismatch prints the full status and exits 2 because skill text already in context cannot be refreshed by re-running — start a fresh session. The pin check reports one executable per project: the project pin first — the first exact spec for this package (x.y.z, =x.y.z or vx.y.z) on the walk up from the nearest package.json, devDependencies then dependencies in each, entering a workspace root and stopping there, never peerDependencies or optionalDependencies — then the Build Packet's campaigns_os_version (the project's campaign-runtime.build.json, or --packet <path>); the Pin: line names the key and manifest (or packet) each version came from; pin.status is match, stale_pin (the pin is not the running version), conflicting_pin (the two sources disagree) or unpinned (neither, or only a range; exit 0). stale_pin and conflicting_pin exit 2 with the file to change; --force (bare) overrides them, is reported as pin.forced and lands on the lifecycle journal entry. See docs/skills-revision.md
479
491
  campaigns-os tooling diagnose [--packet <packet>] [--platform <claude|codex|agents|all>] [--json] # read-only redacted support summary
480
492
  campaigns-os install-agent-context --target <page-kit-dir> [--dry-run]
481
493
  campaigns-os next --packet <json> [--no-write] [--no-remit] [--proxy-base <url>] [--json] # self-decide next stage; returns gates[] + next_actions[] (exact commands) alongside the prompt
@@ -487,21 +499,24 @@ Usage:
487
499
  campaigns-os qa resolve --packet <json> [--base-url <url>] [--no-probe] [--probe-timeout-ms <ms>] [--json] # probes the derived entry URLs; a dead route set reports routes_unresolved, an unprobed one ready_unprobed
488
500
  campaigns-os qa run --packet <json> [--base-url <url>] [--browser] [--test-order <mode>] [--select-package <ref[:qty],...>] [--apply-coupon <code>] [--no-post-verdict] [--no-remit] [--output-dir <dir>] [--json]
489
501
  campaigns-os qa promote --packet <json> --verdict <full-verdict.json> [--json] # project one explicit qa-output verdict to the committed .campaign-runtime/qa-verdict.json sidecar
490
- campaigns-os qa publish --packet <json> [--verdict <full-verdict.json>] [--republish] [--proxy-base <url>] [--json] # post an already-stored verdict (the sidecar's run, or --verdict) to the QA portal without a re-run or an order; refuses a stale spec_hash or an already-published verdict
502
+ campaigns-os qa publish --packet <json> [--verdict <full-verdict.json>] [--republish] [--proxy-base <url>] [--dry-run] [--json] # post an already-stored verdict (the sidecar's run, or --verdict) to the QA portal without a re-run or an order; refuses a stale spec_hash or an already-published verdict. --dry-run runs every one of those refusal checks and prints what would be posted (endpoint, verdict run id, payload bytes) without the POST
491
503
  campaigns-os qa policy set --packet <json> [--allowed-domains-confirmed true|false] [--deploy-target <target>] [--preview-url <url>] [--production-url <url>] [--order-path-depth <off|common|full>] [--json] # --order-path-depth writes qa.proof_policy.order_path_depth and refreshes the assembly report's proof_policy mirror
492
504
  campaigns-os findings add --stage <stage> --kind <kind> --summary <text> [--details <text>] [--packet <json>] [--journal <path>] [--run-id <id>] [...context flags]
493
505
  campaigns-os findings harvest --packet <json> [--context <json>] [--report <json>] [--journal <path>] [--run-id <id>] [--write] [--json]
494
506
  campaigns-os findings list [--packet <json>] [--journal <path>] [--json]
495
507
  campaigns-os findings export [--summary | --json] [--packet <json>] [--journal <path>]
496
- campaigns-os run-record --packet <json> [--context <json>] [--report <json>] [--qa-verdict <path>] [--run-id <id>] [--new-run] [--journal <path>] [--lifecycle-journal <path>] [--surfaces <a,b>] [--primary-surface <s>] [--surface-confidence <text>] [--agent-total-tokens <n>] [--agent-elapsed-ms <n>] [--proxy-base <url>] [--no-remit] [--no-write] [--list] [--json]
508
+ campaigns-os run-record --packet <json> [--context <json>] [--report <json>] [--qa-verdict <path>] [--run-id <id>] [--new-run] [--journal <path>] [--lifecycle-journal <path>] [--surfaces <a,b>] [--primary-surface <s>] [--surface-confidence <text>] [--agent-total-tokens <n>] [--agent-elapsed-ms <n>] [--proxy-base <url>] [--no-remit] [--no-write] [--dry-run] [--list] [--json]
497
509
  run_id: --run-id > the active run session > the most recent Run Record for this packet's campaign (re-emitted in place; a remitted one is left as written) > freshly minted. --new-run always mints; --list prints the run ids on disk for this packet (id, created_at, remit state, path) and, like --no-write, writes and sends nothing.
510
+ --dry-run assembles the record and prints it (with --json: dry_run, would_write, would_remit), then writes no file and sends nothing — where --no-write skips the assembly's reads too. Combining them is allowed and still writes nothing. \`run end --dry-run\` hands the flag on to run-record and leaves the run session open, so the close can still be made for real afterwards.
498
511
 
499
- Any command accepts [--lifecycle-journal <path>] (or env CAMPAIGNS_OS_LIFECYCLE_LOG) to append a command-lifecycle entry (command, argv shape, exit status, timing) for the run; pair with --run-id so run-record can embed it.
512
+ Commands other than login, logout, demo, and tooling diagnose accept [--lifecycle-journal <path>] (or env CAMPAIGNS_OS_LIFECYCLE_LOG) to append a command-lifecycle entry (command, argv shape, exit status, timing) for the run; pair with --run-id so run-record can embed it.
513
+ --no-write suppresses that append for every command, however the journal was selected (flag, env, or the active run session); a refused invocation (unknown command, an unknown subcommand refused before its handler runs, or a flag the command refuses up front) and \`run status\` never append one at all.
514
+ A refused invocation writes no file of its own. One effect still precedes argument refusal: \`start\`, \`prepare-build\`, \`build\`, \`run start\` and \`run end\` close out a STALE run session at the root they are about to act on (Run Record assembled and remitted under the usual consent, session file cleared) before argv is refused — a declared effect of those commands. --no-write suppresses that closeout too, so a --no-write invocation leaves the target byte-identical.
500
515
  campaigns-os telemetry status|on [--proxy-base <url>] [--json] # machine-level Run Telemetry consent (gates remit only; capture is always local). \`on\` records consent for ONE endpoint: the canonical NEXT endpoint by default, or the --proxy-base you name (a loopback or staging receiver); \`status\` reports the stored scope and checks it against the canonical endpoint or the --proxy-base you name
501
516
  campaigns-os telemetry off [--json] # turn remit off for every endpoint (takes no --proxy-base)
502
517
  campaigns-os telemetry list [--packet <json> | --admin-key-env <VAR>] [--since <ISO>] [--package <v>] [--surface <s>] [--trusted] [--limit <n>] [--proxy-base <url>] [--trust-proxy-base] [--json] # read stored Run Records: tenant scope via the packet's campaign key, or cross-tenant via the ops admin key (default env CAMPAIGN_OPS_ADMIN_KEY). --proxy-base must be https unless it is a loopback host (allowed over http, with a warning that the credential is in clear).
503
518
  campaigns-os run start [--packet <json>] [--run-id <id>] [--lifecycle-journal <path>] [--force] [--json] # begin an ambient run session: one run_id + journal auto-shared by every command, no per-command flags; with --packet the session lives in the packet's target repo, whatever the cwd
504
- campaigns-os run status [--json] # active session + incomplete stages + deviation count + exact next command
519
+ campaigns-os run status [--json] # active session + incomplete stages + deviation count + exact next command; read-only — it never sweeps and never journals
505
520
  campaigns-os run end [--packet <json>] [--no-remit] [--no-write] [--proxy-base <url>] [--json] # assemble the aggregated Run Record for the session, then clear it (also closes out a stale session at cwd); --proxy-base is handed to run-record, so the session record remits to that receiver under the consent scoped to it
506
521
 
507
522
  Gates: when theme inspect finds a generatable brand theme and the campaign ships commerce pages, \`next polish|deploy|qa\` and \`qa run\` BLOCK until the brand layer is applied after next-core.css or explicitly waived (\`theme waive\` / \`qa run --theme-waive "<reason>"\`).
@@ -535,9 +550,15 @@ Examples:
535
550
  npm run campaigns-os -- standardize --target examples/target-page-kit --json
536
551
  `;
537
552
 
553
+ // `refused()`, its `REFUSED_INVOCATION` tag, and the `refusalSeen()` /
554
+ // `runWithRefusalScope()` accessors live in lifecycle.mjs — see the contract
555
+ // there.
556
+ // Command modules raise refusals too (`qa`'s unknown subcommand) and cli.mjs
557
+ // imports them, so the factory has to sit below both.
558
+
538
559
  // Top-level commands the CLI dispatches, used to offer a did-you-mean
539
560
  // suggestion on a typo instead of a bare "Unknown command". Derived from the
540
- // `command === "…"` literals in dispatch() itself (memoized on first use) so
561
+ // `command === "…"` literals in main() and dispatch() (memoized on first use) so
541
562
  // the list cannot drift as dispatch branches are added or removed. The regex
542
563
  // tolerates whitespace and either quote style so common reformats don't
543
564
  // silently empty the list; a known-commands test guards against a refactor
@@ -546,7 +567,7 @@ let knownCommandsCache = null;
546
567
  export function knownCommands() {
547
568
  if (knownCommandsCache) return knownCommandsCache;
548
569
  const found = new Set(["help"]);
549
- for (const match of dispatch.toString().matchAll(/command\s*===\s*["']([^"']+)["']/g)) {
570
+ for (const match of (main.toString() + dispatch.toString()).matchAll(/command\s*===\s*["']([^"']+)["']/g)) {
550
571
  found.add(match[1]);
551
572
  }
552
573
  knownCommandsCache = [...found];
@@ -593,7 +614,7 @@ function closestCommand(input) {
593
614
  return bestDistance <= budget ? best : null;
594
615
  }
595
616
 
596
- export async function main(argv) {
617
+ export async function main(argv, { authentication } = {}) {
597
618
  const args = parseArgs(argv);
598
619
  // `npx --yes -p <spec> campaigns-os <command>` and `npx --yes <spec>
599
620
  // campaigns-os <command>` both hand the bin its own name as the first
@@ -603,65 +624,88 @@ export async function main(argv) {
603
624
  if (args._[0] === "campaigns-os") args._.shift();
604
625
  const command = args._[0] || "help";
605
626
 
606
- // An offline sample must not recover sessions or emit lifecycle evidence.
607
- if (command === "demo") {
608
- // Validate raw tokens here: parsing loses duplicate flags. The private
609
- // dispatcher then rechecks the parsed shape and extracts the target.
610
- demoArguments(args, argv);
611
- await dispatch(command, args);
612
- return;
613
- }
627
+ // Everything below — dispatch and the onFinish that reads the verdict — runs
628
+ // inside ONE refusal scope, so a refusal raised by this invocation is visible
629
+ // only to this invocation's persistence step. Two main() calls interleaved
630
+ // in-process (a test, an embedding host) no longer share the verdict.
631
+ return runWithRefusalScope(async () => {
632
+ // Authentication never recovers/remits run sessions or records argv in a
633
+ // lifecycle journal. Credentials belong only in the user credential store.
634
+ if (command === "login" || command === "logout") {
635
+ const { runAuthentication } = await import("./login.mjs");
636
+ return runAuthentication(argv[0] === "campaigns-os" ? argv.slice(1) : argv, authentication);
637
+ }
638
+
639
+ // An offline sample must not recover sessions or emit lifecycle evidence.
640
+ if (command === "demo") {
641
+ // Validate raw tokens here: parsing loses duplicate flags. The private
642
+ // dispatcher then rechecks the parsed shape and extracts the target.
643
+ demoArguments(args, argv);
644
+ await dispatch(command, args);
645
+ return;
646
+ }
614
647
 
615
- // Diagnostic export is an inspection, including when a run is active or
616
- // stale. Bypass session sweeping, ambient resolution, and lifecycle capture
617
- // so no closeout/remit or journal write can occur before the projection.
618
- if (command === "tooling" && args._[1] === "diagnose") {
619
- const result = toolingDiagnose(args);
620
- console.log(args.json ? JSON.stringify(result, null, 2) : diagnosticTextLines(result).join("\n"));
621
- return;
622
- }
648
+ // Diagnostic export is an inspection, including when a run is active or
649
+ // stale. Bypass session sweeping, ambient resolution, and lifecycle capture
650
+ // so no closeout/remit or journal write can occur before the projection.
651
+ if (command === "tooling" && args._[1] === "diagnose") {
652
+ const result = toolingDiagnose(args);
653
+ console.log(args.json ? JSON.stringify(result, null, 2) : diagnosticTextLines(result).join("\n"));
654
+ return;
655
+ }
623
656
 
624
- // Ambient run session (Tier 3): when `run start` is active, every command
625
- // shares its run_id WITHOUT --run-id. Explicit --run-id still wins. Resolved
626
- // ONCE here and threaded through dispatch + persistence so the run_id a
627
- // command is tagged with and the journal it writes to come from a single
628
- // read (no TOCTOU skew if the session changes mid-run).
629
- //
630
- // Before that read, close out any STALE session at the root this command is
631
- // about to open a new one in. findRunSession ignores stale sessions so a new
632
- // run never inherits an old run_id — but an ignored session was also an
633
- // abandoned one: nine of them were found lingering with no Run Record and
634
- // nothing remitted. Closing out is best-effort and never blocks the command.
635
- const storageInspection = command === "sdk" && args._[1] === "storage-check";
636
- const sweptStale = storageInspection ? [] : await closeOutStaleRunSessions(command, args);
637
- const ambient = ambientRunSession(args);
638
-
639
- // Wrap every command in the lifecycle instrumentation (T6): it captures the
640
- // command, its argv shape, exit status, and timing. Re-throws unchanged so
641
- // the CLI exit code is unaffected. Persistence runs via onFinish so it fires
642
- // on BOTH the success and error paths — a command that THROWS (the most
643
- // valuable failure telemetry) is recorded too, not just clean exits.
644
- // Persistence is OPT-IN — an explicit --lifecycle-journal /
645
- // CAMPAIGNS_OS_LIFECYCLE_LOG, or an active run session. With none, behavior
646
- // is identical to before.
647
- //
648
- // `sessionHolder` is per-invocation, NOT module state: when start/
649
- // prepare-build auto-open a run session mid-command, they publish it here
650
- // so onFinish persists this command's own lifecycle entry into the new
651
- // session — without two interleaved invocations ever sharing a session.
652
- const sessionHolder = { current: ambient, autoStarted: false, adopted: false, qaResult: null, sweptStale };
653
- await withCommandLifecycle(
654
- {
655
- command,
656
- argvShape: argvShape(args),
657
- runId: optionalString(args["run-id"]) || ambient?.session?.run_id || null,
658
- onFinish: async (lifecycle, thrown) => {
659
- persistLifecycleIfRequested(args, command, lifecycle, sessionHolder);
660
- await autoEndRunSessionAfterTerminalQa(args, command, sessionHolder, thrown);
657
+ // Ambient run session (Tier 3): when `run start` is active, every command
658
+ // shares its run_id WITHOUT --run-id. Explicit --run-id still wins. Resolved
659
+ // ONCE here and threaded through dispatch + persistence so the run_id a
660
+ // command is tagged with and the journal it writes to come from a single
661
+ // read (no TOCTOU skew if the session changes mid-run).
662
+ //
663
+ // Before that read, close out any STALE session at the root this command is
664
+ // about to open a new one in. findRunSession ignores stale sessions so a new
665
+ // run never inherits an old run_id — but an ignored session was also an
666
+ // abandoned one: nine of them were found lingering with no Run Record and
667
+ // nothing remitted. Closing out is best-effort and never blocks the command.
668
+ const storageInspection = command === "sdk" && args._[1] === "storage-check";
669
+ // `readback` bypasses session resolution entirely, sweep included. Its
670
+ // `--packet` is a readback OVERRIDE naming the Build Packet to project, not a
671
+ // Build Packet to act on, and ambientRunSession treats that flag as a session
672
+ // locator: it read the named file whole through readJson, so a 40 MB packet
673
+ // was loaded into memory past readback's own 32 MiB bound before readback
674
+ // ever saw it, and a valid override exited 1 whenever some active session was
675
+ // bound to a different packet. Neither belongs to a command declared
676
+ // read-only. The lifecycle wrapper below still runs; the read-only exemption
677
+ // lives in persistLifecycleIfRequested, which writes no entry for readback.
678
+ const readOnlyProjection = command === "readback";
679
+ const sweptStale = storageInspection || readOnlyProjection ? [] : await closeOutStaleRunSessions(command, args);
680
+ const ambient = readOnlyProjection ? null : ambientRunSession(args);
681
+
682
+ // Wrap every command in the lifecycle instrumentation (T6): it captures the
683
+ // command, its argv shape, exit status, and timing. Re-throws unchanged so
684
+ // the CLI exit code is unaffected. Persistence runs via onFinish so it fires
685
+ // on BOTH the success and error paths — a command that THROWS (the most
686
+ // valuable failure telemetry) is recorded too, not just clean exits.
687
+ // Persistence is OPT-IN — an explicit --lifecycle-journal /
688
+ // CAMPAIGNS_OS_LIFECYCLE_LOG, or an active run session. With none, behavior
689
+ // is identical to before.
690
+ //
691
+ // `sessionHolder` is per-invocation, NOT module state: when start/
692
+ // prepare-build auto-open a run session mid-command, they publish it here
693
+ // so onFinish persists this command's own lifecycle entry into the new
694
+ // session — without two interleaved invocations ever sharing a session.
695
+ const sessionHolder = { current: ambient, autoStarted: false, adopted: false, qaResult: null, sweptStale };
696
+ await withCommandLifecycle(
697
+ {
698
+ command,
699
+ argvShape: argvShape(args),
700
+ runId: optionalString(args["run-id"]) || ambient?.session?.run_id || null,
701
+ onFinish: async (lifecycle, thrown) => {
702
+ persistLifecycleIfRequested(args, command, lifecycle, sessionHolder, thrown);
703
+ await autoEndRunSessionAfterTerminalQa(args, command, sessionHolder, thrown);
704
+ },
661
705
  },
662
- },
663
- (recorder) => dispatch(command, args, recorder, ambient, sessionHolder),
664
- );
706
+ (recorder) => dispatch(command, args, recorder, ambient, sessionHolder),
707
+ );
708
+ });
665
709
  }
666
710
 
667
711
  function ambientRunSession(args = {}) {
@@ -777,14 +821,72 @@ function resolveLifecycleJournal(args, { ambient = null, fallbackDir = null } =
777
821
  return fallbackDir ? join(resolve(fallbackDir), LIFECYCLE_JOURNAL_REL_PATH) : null;
778
822
  }
779
823
 
824
+ // The commands and subcommands that actually IMPLEMENT `--dry-run`. The flag
825
+ // reaches every handler through a permissive parseArgs, so it is silently
826
+ // accepted everywhere — and the lifecycle exemption below, scoped to the flag
827
+ // alone, therefore fired on commands that ignore it: `qa run --dry-run` placed
828
+ // orders while writing no journal entry, and (see runSessionEndArgs) carried
829
+ // the flag into its own auto-end, which assembled no Run Record and left the
830
+ // session open. Keyed by `command`, or `command <args._[1]>` where the flag
831
+ // belongs to one subcommand. A command outside this set given `--dry-run`
832
+ // behaves exactly as it did before: it journals if it otherwise would, and it
833
+ // is not refused — refusing unknown flags is separate work.
834
+ const DRY_RUN_COMMANDS = new Set([
835
+ "page-kit sync",
836
+ "spec derive",
837
+ "install-skills",
838
+ "install-agent-context",
839
+ "run-record",
840
+ "run end",
841
+ "qa publish",
842
+ "checkpoint waive",
843
+ "theme waive",
844
+ ]);
845
+
846
+ function commandImplementsDryRun(command, args = {}) {
847
+ return DRY_RUN_COMMANDS.has(command) || DRY_RUN_COMMANDS.has(`${command} ${args._?.[1]}`);
848
+ }
849
+
780
850
  // Append the command's lifecycle entry only when capture is active: an explicit
781
851
  // flag/env, or an ambient run session. Never throws — a lifecycle write must
782
852
  // not break a command (telemetry never blocks a build). `help` is a no-op
783
853
  // command and is not worth recording.
784
- function persistLifecycleIfRequested(args, command, lifecycle, sessionHolder) {
854
+ function persistLifecycleIfRequested(args, command, lifecycle, sessionHolder, thrown) {
785
855
  if (command === "help" || (command === "sdk" && args._[1] === "storage-check")) return;
856
+ // Three rules about what NEVER reaches the journal, whichever way the journal
857
+ // was selected (--lifecycle-journal, CAMPAIGNS_OS_LIFECYCLE_LOG, or an
858
+ // ambient run session). The in-process lifecycle object is still built; only
859
+ // the persistence below — the journal append and the deviation entry that
860
+ // follows it — is skipped, so a suppressed command still exits as before.
861
+ // 1. --no-write writes nothing, the journal included (issue #459: `run
862
+ // status --no-write` under an ambient session still created
863
+ // .campaign-runtime/command-lifecycle.jsonl).
864
+ // 2. A refused INVOCATION records nothing — an unknown top-level command,
865
+ // an unknown subcommand (`tooling statuss`), or a flag the command
866
+ // refuses up front (`standardize --dryrun`). None of them reached a
867
+ // handler, so a typo must not materialize a journal under the target.
868
+ // The tag the refusal carries IS the mechanism, read two ways: on the
869
+ // thrown error, or via refusalSeen() when the refusal was caught and
870
+ // rendered instead of thrown. There is deliberately no command-list
871
+ // backstop here — knownCommands() is regex-harvested and documented as
872
+ // fragile, so a second reading of it would be a second command list that
873
+ // could disagree with dispatch.
874
+ // 3. `run status` is read-only: it never sweeps and never journals.
875
+ if (args["no-write"] === true) return;
876
+ if (refusalSeen() || thrown?.code === REFUSED_INVOCATION) return;
877
+ if (command === "run" && args._[1] === "status") return;
786
878
  // An inspection must not append to a delivered campaign's active run either.
787
- if (command === "doctor" && args.packet && (args.write !== true || args["no-write"] === true)) return;
879
+ // (--no-write is handled above, so only the read-only `doctor` form is left.)
880
+ if (command === "doctor" && args.packet && args.write !== true) return;
881
+ // The same rule, per-flag, for every command that takes `--dry-run`: the
882
+ // flag's whole promise is that the invocation writes nothing under the
883
+ // target, and the journal lives under the target. Only for the commands that
884
+ // make that promise, though — DRY_RUN_COMMANDS above.
885
+ if (args["dry-run"] === true && commandImplementsDryRun(command, args)) return;
886
+ // `readback` is declared read-only for the whole command, not per-flag: it
887
+ // writes nothing under the target, so a journal entry would be the one write
888
+ // its own contract forbids. Skipped the way doctor inspection is skipped.
889
+ if (command === "readback") return;
788
890
  const ambient = sessionHolder?.current || null;
789
891
  // A session auto-started DURING this command (start/prepare-build) is
790
892
  // published into sessionHolder by autoStartRunSession; this command's own
@@ -1009,9 +1111,18 @@ async function autoEndRunSessionAfterTerminalQa(args, command, sessionHolder, th
1009
1111
  return;
1010
1112
  }
1011
1113
 
1114
+ // `dry-run` is inheritable because `run end --dry-run` hands it to
1115
+ // run-record on purpose. The invoking command here is `qa run`, which does
1116
+ // not implement the flag, so inheriting it would turn its own auto-end into
1117
+ // a dry run: no Run Record written, and the session left open after a
1118
+ // terminal QA. Every other inheritable flag `qa run` may carry is one
1119
+ // run-record reads the same way whoever passed it.
1120
+ const extraArgs = { ...args, "qa-verdict": result.local_path };
1121
+ if (!commandImplementsDryRun(command, args)) delete extraArgs["dry-run"];
1122
+
1012
1123
  const summary = await closeRunSession(updatedFound, {
1013
1124
  packet,
1014
- extraArgs: { ...args, "qa-verdict": result.local_path },
1125
+ extraArgs,
1015
1126
  silent: true,
1016
1127
  promptForConsent: false,
1017
1128
  onError: (error) => process.stderr.write(`[campaigns-os] run session auto-end skipped after QA: ${error.message}\n`),
@@ -1083,7 +1194,7 @@ async function dispatch(command, args, recorder = NOOP_RECORDER, ambient = null,
1083
1194
 
1084
1195
  if (command === "bundle") {
1085
1196
  const subcommand = args._[1] || "check";
1086
- if (subcommand !== "check") throw new Error('Unknown bundle subcommand. Use: campaigns-os bundle check --packet <campaign-runtime.build.json> [--require-qa] [--json].');
1197
+ if (subcommand !== "check") throw refused('Unknown bundle subcommand. Use: campaigns-os bundle check --packet <campaign-runtime.build.json> [--require-qa] [--json].');
1087
1198
  const { inspectSidecarBundle, sidecarBundleReadinessLine } = await import("./sidecar-bundle.mjs");
1088
1199
  const result = inspectSidecarBundle({
1089
1200
  packetPath: requireArg(args, "packet"),
@@ -1097,10 +1208,10 @@ async function dispatch(command, args, recorder = NOOP_RECORDER, ambient = null,
1097
1208
  }
1098
1209
 
1099
1210
  if (command === "sdk") {
1100
- if (args._[1] !== "storage-check" || args._.length !== 2) throw new Error("Use: campaigns-os sdk storage-check --target <git-root> --target-sdk <x.y.z> --manifest <SDK-manifest.json> --scope <dir,file> [--exclude <dir,file>] [--json].");
1211
+ if (args._[1] !== "storage-check" || args._.length !== 2) throw refused("Use: campaigns-os sdk storage-check --target <git-root> --target-sdk <x.y.z> --manifest <SDK-manifest.json> --scope <dir,file> [--exclude <dir,file>] [--json].");
1101
1212
  const known = new Set(["_", "target", "target-sdk", "manifest", "scope", "exclude", "json"]);
1102
- if (args.json !== undefined && args.json !== true) throw new Error("--json is a boolean flag and takes no value.");
1103
- for (const key of Object.keys(args)) if (!known.has(key)) throw new Error(`Unknown SDK storage-check flag: --${key}`);
1213
+ if (args.json !== undefined && args.json !== true) throw refused("--json is a boolean flag and takes no value.");
1214
+ for (const key of Object.keys(args)) if (!known.has(key)) throw refused(`Unknown SDK storage-check flag: --${key}`);
1104
1215
  const { scanSdkStorageCompatibility, formatStorageCompatibilityReport } = await import("./sdk-storage-compatibility.mjs");
1105
1216
  const result = scanSdkStorageCompatibility({
1106
1217
  cwd: requireArg(args, "target"),
@@ -1145,6 +1256,21 @@ async function dispatch(command, args, recorder = NOOP_RECORDER, ambient = null,
1145
1256
  return;
1146
1257
  }
1147
1258
 
1259
+ if (command === "readback") {
1260
+ // Read-only projection over a target's already-emitted artifacts. It owns
1261
+ // its own exit codes (0 for any projection it could form, 2 for a request
1262
+ // that cannot form one) rather than throwing, so a usage error reads as a
1263
+ // one-line refusal instead of a stack-shaped CLI error.
1264
+ const { runReadbackCommand } = await import("./readback.mjs");
1265
+ const { exitCode, text } = runReadbackCommand(args);
1266
+ if (exitCode === 0) process.stdout.write(text);
1267
+ else {
1268
+ process.stderr.write(text);
1269
+ process.exitCode = 2;
1270
+ }
1271
+ return;
1272
+ }
1273
+
1148
1274
  if (command === "validate-assembly-report") {
1149
1275
  const reportPath = requireArg(args, "report");
1150
1276
  const result = validateAssemblyReport(readJson(resolve(reportPath)));
@@ -1160,8 +1286,8 @@ async function dispatch(command, args, recorder = NOOP_RECORDER, ambient = null,
1160
1286
  }
1161
1287
 
1162
1288
  if (command === "install-skills") {
1163
- if (args.target === true) throw new Error("Missing value for --target");
1164
- if (args.platform === true) throw new Error("Missing value for --platform");
1289
+ if (args.target === true) throw refused("Missing value for --target");
1290
+ if (args.platform === true) throw refused("Missing value for --platform");
1165
1291
  const result = installSkills(args.target, Boolean(args["dry-run"]), args.platform);
1166
1292
  writeResult(result, args, 0);
1167
1293
  return;
@@ -1172,7 +1298,7 @@ async function dispatch(command, args, recorder = NOOP_RECORDER, ambient = null,
1172
1298
  // Spelled as inequalities: knownCommands() harvests the top-level
1173
1299
  // command literals from this function by an equality pattern that a
1174
1300
  // subcommand equality would also match.
1175
- if (subcommand !== "sync" && subcommand !== "parity") throw new Error("Unknown page-kit subcommand. Use: campaigns-os page-kit sync --packet <campaign-runtime.build.json> [--dry-run] [--json], or campaigns-os page-kit parity --packet <campaign-runtime.build.json> [--report <json>] [--json].");
1301
+ if (subcommand !== "sync" && subcommand !== "parity") throw refused("Unknown page-kit subcommand. Use: campaigns-os page-kit sync --packet <campaign-runtime.build.json> [--dry-run] [--json], or campaigns-os page-kit parity --packet <campaign-runtime.build.json> [--report <json>] [--json].");
1176
1302
  const parity = subcommand !== "sync";
1177
1303
  const result = parity ? pageKitParityCommand(args) : pageKitSyncCommand(args);
1178
1304
  if (args.json) console.log(JSON.stringify(result, null, 2));
@@ -1185,7 +1311,7 @@ async function dispatch(command, args, recorder = NOOP_RECORDER, ambient = null,
1185
1311
  const subcommand = args._[1] || null;
1186
1312
  // Inequality on purpose: knownCommands() harvests top-level command
1187
1313
  // literals by an equality pattern a subcommand equality would also match.
1188
- if (subcommand !== "derive") throw new Error("Unknown spec subcommand. Use: campaigns-os spec derive --packet <campaign-runtime.build.json> [--dry-run] [--json].");
1314
+ if (subcommand !== "derive") throw refused("Unknown spec subcommand. Use: campaigns-os spec derive --packet <campaign-runtime.build.json> [--dry-run] [--json].");
1189
1315
  const result = await specDeriveWithMapWriteback(args);
1190
1316
  if (args.json) console.log(JSON.stringify(result, null, 2));
1191
1317
  else for (const line of specDeriveWriteMapTextLines(result)) console.log(line);
@@ -1194,8 +1320,11 @@ async function dispatch(command, args, recorder = NOOP_RECORDER, ambient = null,
1194
1320
  }
1195
1321
 
1196
1322
  if (command === "tooling") {
1197
- const result = toolingCommand(args);
1198
- writeResult(result, args, result.ok ? 0 : 2);
1323
+ const result = await toolingStatusCommand(args);
1324
+ // The revision line is a header, so a mismatch is stated before the status
1325
+ // an operator would otherwise read as fine — and the full status still
1326
+ // prints, because the exit code is set after the render, not instead of it.
1327
+ writeResult(result, args, result.ok ? 0 : 2, { headerLines: [...skillsRevisionTextLines(result), ...pinTextLines(result)] });
1199
1328
  return;
1200
1329
  }
1201
1330
 
@@ -1252,7 +1381,7 @@ async function dispatch(command, args, recorder = NOOP_RECORDER, ambient = null,
1252
1381
 
1253
1382
  const suggestion = closestCommand(command);
1254
1383
  const didYouMean = suggestion ? ` Did you mean "${suggestion}"?` : "";
1255
- throw new Error(
1384
+ throw refused(
1256
1385
  `Unknown command: ${command}.${didYouMean} Run \`campaigns-os --help\` to see available commands.`,
1257
1386
  );
1258
1387
  }
@@ -1277,12 +1406,25 @@ function parseArgs(argv) {
1277
1406
  return args;
1278
1407
  }
1279
1408
 
1409
+ // The shared "missing required flag" refusal. Every call site resolves flags
1410
+ // at the top of its handler, before the command reads or writes anything, so
1411
+ // this is always an up-front refusal and carries the tag.
1280
1412
  function requireArg(args, key) {
1281
1413
  const value = args[key];
1282
- if (!isNonEmptyString(value)) throw new Error(`Missing required --${key}`);
1414
+ if (!isNonEmptyString(value)) throw refused(`Missing required --${key}`);
1283
1415
  return value;
1284
1416
  }
1285
1417
 
1418
+ // `--dry-run` is a bare flag on every command that takes one. The shared
1419
+ // parser would read a following token as its value, so `--dry-run true` must
1420
+ // fail rather than quietly become a real write or a real send.
1421
+ function isDryRun(args) {
1422
+ if (Object.hasOwn(args, "dry-run") && args["dry-run"] !== true) {
1423
+ throw refused(`--dry-run takes no value (got ${JSON.stringify(args["dry-run"])}); write \`--dry-run\` on its own, after the other flags.`);
1424
+ }
1425
+ return args["dry-run"] === true;
1426
+ }
1427
+
1286
1428
  function isObject(value) {
1287
1429
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
1288
1430
  }
@@ -1355,7 +1497,7 @@ async function resolveSpecPath(args, opts = {}) {
1355
1497
  const mapId = String(args["map-id"]).trim();
1356
1498
  const targetRepo = opts.targetRepo || (args.target ? resolve(args.target) : null);
1357
1499
  if (!targetRepo) {
1358
- throw new Error("--map-id requires --target (so the fetched spec can be cached under <target>/.campaign-runtime/).");
1500
+ throw refused("--map-id requires --target (so the fetched spec can be cached under <target>/.campaign-runtime/).");
1359
1501
  }
1360
1502
  const proxyBase = optionalString(args["proxy-base"], DEFAULT_PROXY_BASE);
1361
1503
  const cacheDir = join(targetRepo, ".campaign-runtime", "fetched-specs");
@@ -1374,7 +1516,7 @@ async function resolveSpecPath(args, opts = {}) {
1374
1516
  algorithm: "map-store-v1", local_spec_material_hash: specMaterialHash(spec) },
1375
1517
  };
1376
1518
  }
1377
- throw new Error(
1519
+ throw refused(
1378
1520
  "Either --spec <path> or --map-id <id> is required. " +
1379
1521
  "Pass a local CampaignSpec (--spec <path-to-campaignspec.json>) " +
1380
1522
  "or fetch one from Map Builder (--map-id <id> --target <page-kit-dir>).",
@@ -2444,6 +2586,9 @@ function prepareBuild(args, options = {}) {
2444
2586
  const packet = {
2445
2587
  schema_version: PACKET_SCHEMA,
2446
2588
  generated_at: new Date().toISOString(),
2589
+ // The kernel version that prepared this packet: the second pin source
2590
+ // `tooling status` reads when the project declares no exact devDependency.
2591
+ campaigns_os_version: packageVersion(),
2447
2592
  campaign: {
2448
2593
  public_route_slug: publicRouteSlug,
2449
2594
  ...(specRouteRoot ? { route_root: specRouteRoot } : {}),
@@ -3218,7 +3363,7 @@ function rejectUnknownStandardizeFlags(args) {
3218
3363
  const valueHint = unknown.some((key) => key.includes("="))
3219
3364
  ? " A flag takes its value as the next argument (--flag value), not --flag=value."
3220
3365
  : "";
3221
- throw new Error(
3366
+ throw refused(
3222
3367
  `Unknown flag${unknown.length > 1 ? "s" : ""} for standardize: ${unknown.map((key) => `--${key}`).join(", ")}.${valueHint} Known flags: ${STANDARDIZE_FLAGS.map((key) => `--${key}`).join(", ")}.`,
3223
3368
  );
3224
3369
  }
@@ -3227,7 +3372,7 @@ function standardizationReportCommand(args) {
3227
3372
  rejectUnknownStandardizeFlags(args);
3228
3373
  const target = optionalString(args.target);
3229
3374
  if (!target) {
3230
- throw new Error("standardize requires --target <campaign-repo> (a Page Kit root, a parent repo, or a Campaign Cart application checkout).");
3375
+ throw refused("standardize requires --target <campaign-repo> (a Page Kit root, a parent repo, or a Campaign Cart application checkout).");
3231
3376
  }
3232
3377
  const family = optionalString(args.family) || optionalString(args["template-family"]);
3233
3378
  const slug = optionalString(args.slug);
@@ -3283,7 +3428,7 @@ function standardizationReportCommand(args) {
3283
3428
  function themeCommand(args) {
3284
3429
  const subcommand = args._[1] || "inspect";
3285
3430
  if (!["inspect", "generate", "waive"].includes(subcommand)) {
3286
- throw new Error(`Unknown theme subcommand "${subcommand}". Use: inspect | generate | waive.`);
3431
+ throw refused(`Unknown theme subcommand "${subcommand}". Use: inspect | generate | waive.`);
3287
3432
  }
3288
3433
  if (subcommand === "waive") return themeWaive(args);
3289
3434
  const packetPath = resolve(requireArg(args, "packet"));
@@ -3325,20 +3470,25 @@ function themeCommand(args) {
3325
3470
  // improvising past advisory prose.
3326
3471
  export function themeWaive(args) {
3327
3472
  const packetPath = resolve(requireArg(args, "packet"));
3473
+ const dryRun = isDryRun(args);
3328
3474
  const packet = readJson(packetPath);
3329
3475
  const reason = optionalString(args.reason);
3330
- if (!reason) throw new Error("theme waive requires --reason \"<why the starter palette is acceptable for this campaign>\".");
3476
+ // A missing flag, raised after reading nothing but argv and the packet: a
3477
+ // refusal, so the journal records nothing for it (docs/effects.md `*refused*`).
3478
+ if (!reason) throw refused("theme waive requires --reason \"<why the starter palette is acceptable for this campaign>\".");
3331
3479
  // The same attribution rule as `checkpoint waive`: a named human, no
3332
3480
  // placeholder, an expiry (when given) that lies in the future and is
3333
3481
  // recorded. A bound is not demanded here: the theme gate's waiver has always
3334
3482
  // been open-ended, and QA re-surfaces the starter palette on every run.
3335
- const waiver = validateWaiverAttribution({
3483
+ // A shared validator of argv alone, still ahead of the report read: its
3484
+ // throws are refusals at this call site (the position decides, not the file).
3485
+ const waiver = refusing(() => validateWaiverAttribution({
3336
3486
  reason,
3337
3487
  waivedBy: args["waived-by"] == null ? null : String(args["waived-by"]),
3338
3488
  expiresAt: args["expires-at"] == null ? null : String(args["expires-at"]),
3339
3489
  requireBound: false,
3340
3490
  label: "theme waive",
3341
- });
3491
+ }));
3342
3492
  const workspace = resolveCampaignWorkspace(packetPath, {
3343
3493
  packet,
3344
3494
  reportPath: args.report ? resolve(args.report) : undefined,
@@ -3346,7 +3496,7 @@ export function themeWaive(args) {
3346
3496
  });
3347
3497
  const { reportPath } = workspace;
3348
3498
  if (!existsSync(reportPath)) throw new Error(`theme waive needs an assembly report at ${reportPath}; run prepare-build/start first.`);
3349
- commitAssemblyReport(workspace, (report) => {
3499
+ const recordWaiver = (report) => {
3350
3500
  report.theme = report.theme && isObject(report.theme)
3351
3501
  ? { ...report.theme, waiver }
3352
3502
  : { status: "skipped", css_path: null, load_order: "not-applied", commerce_pages: [], evidence: [], warnings: [], repair_loop_defect: null, waiver };
@@ -3355,12 +3505,37 @@ export function themeWaive(args) {
3355
3505
  `Theme gate waived by ${waiver.waived_by} at ${waiver.waived_at}: ${reason}`,
3356
3506
  ];
3357
3507
  return report;
3358
- }, {
3508
+ };
3509
+ // Every check above is the real command's; only the commit is skipped. The
3510
+ // readiness block is not reported for a dry run because doctor reads it off
3511
+ // the report on disk, which by construction still has no waiver on it.
3512
+ //
3513
+ // Both modes go through the committing path itself (see
3514
+ // commitWaiverToAssemblyReport): the dry run used to return on the existsSync
3515
+ // check alone, so a torn report was reported as a successful `would_write`
3516
+ // with exit 0 while the real invocation refused with "Assembly Report ... is
3517
+ // not valid JSON" and exit 1, and a report that is not an object was previewed
3518
+ // as writable while the commit refused it. Validation must never be weaker
3519
+ // under --dry-run than without it.
3520
+ commitWaiverToAssemblyReport(workspace, recordWaiver, {
3359
3521
  // #171: the waiver changes what doctor would conclude; the retained doctor
3360
3522
  // sidecar (if any) now predates it.
3361
3523
  command: "theme waive",
3362
3524
  staleReason: `A theme-gate waiver was recorded after this doctor snapshot. Re-run ${cmd("doctor")} (or next) for current state.`,
3363
- });
3525
+ }, { dryRun });
3526
+ if (dryRun) {
3527
+ return {
3528
+ ok: true,
3529
+ status: "dry_run",
3530
+ dry_run: true,
3531
+ action: "theme-waive",
3532
+ gate: "theme_gate",
3533
+ waiver,
3534
+ report_path: reportPath,
3535
+ would_write: reportPath,
3536
+ note: "Dry run: nothing was written and the doctor sidecar was not marked stale. Re-run without --dry-run to record this waiver.",
3537
+ };
3538
+ }
3364
3539
  return {
3365
3540
  ok: true,
3366
3541
  ...waiveReadiness(packetPath, reportPath),
@@ -3378,6 +3553,44 @@ export function themeWaive(args) {
3378
3553
  // "unknown" fallback. Doctor is re-run rather than patched from the pre-waive
3379
3554
  // result because a waiver changes what every other gate concludes about the
3380
3555
  // stage. Nothing is persisted here; the sidecar was already marked stale.
3556
+ /**
3557
+ * The one route both waive commands take to the Assembly Report, real or
3558
+ * previewed. `commitAssemblyReport` is called identically in both modes — same
3559
+ * workspace, same mutator, same options — so every check the committing path
3560
+ * makes runs on both: the report must exist, it must parse (a torn one fails by
3561
+ * name), the mutator's own refusals fire, its result must be an Assembly Report
3562
+ * object, and the derived summary is restated over that result. A dry run
3563
+ * differs in one statement: this wrapper — not the mutator — returns `null` to
3564
+ * commitAssemblyReport, which is its "nothing to write" answer, so the report
3565
+ * is not rewritten and the doctor sidecar is not stamped stale. Nothing is
3566
+ * re-implemented and nothing is skipped but the write itself. A mutator that
3567
+ * returns nothing does not get to borrow that sentinel: it is a bug, and it
3568
+ * throws here on both paths.
3569
+ *
3570
+ * The result-shape check and the summary restatement sit here rather than being
3571
+ * left to commitAssemblyReport alone because they must run in BOTH modes and
3572
+ * commitAssemblyReport reaches its own copies only on the way to the write.
3573
+ * Running them here means one message and one order, not two: the real path
3574
+ * now fails on this line and never on the copy in stage-ledger.mjs, so the two
3575
+ * cannot drift into different text.
3576
+ */
3577
+ export function commitWaiverToAssemblyReport(workspace, mutate, options, { dryRun = false } = {}) {
3578
+ const previewOrCommit = (report) => {
3579
+ const mutated = mutate(report);
3580
+ // null and undefined are refused here rather than forwarded. Both waive
3581
+ // mutators always return the report they built, and commitAssemblyReport
3582
+ // reads a null result as "nothing to write" — so a mutator that forgot to
3583
+ // return would skip the write silently while the command still reported the
3584
+ // waiver recorded. That is a bug in the mutator and fails loudly. The dry
3585
+ // run's own "do not write" is this wrapper's null below, not the mutator's.
3586
+ if (!isPlainObject(mutated)) throw new TypeError("commitAssemblyReport mutate(report) must return an Assembly Report object.");
3587
+ if (!dryRun) return mutated;
3588
+ applyDerivedAssemblyReportSummary(mutated);
3589
+ return null;
3590
+ };
3591
+ return commitAssemblyReport(workspace, previewOrCommit, options);
3592
+ }
3593
+
3381
3594
  function waiveReadiness(packetPath, reportPath) {
3382
3595
  const doctor = doctorPacket(packetPath, { reportPath });
3383
3596
  return { status: doctor.status, next_stage: doctor.next?.stage || null, next_stage_reason: doctor.next?.reason || null };
@@ -3400,14 +3613,14 @@ function requireValidPolishCaptureReport(report, reportPath) {
3400
3613
  export async function polishCaptureCommand(args, options = {}) {
3401
3614
  const subcommand = args?._?.[1] || "help";
3402
3615
  if (subcommand !== "capture") {
3403
- throw new Error(
3616
+ throw refused(
3404
3617
  `Unknown polish subcommand. Use: ${cmd("polish")} capture --packet <campaign-runtime.build.json> --base-url <url> [--report <json>] [--headed] [--auth-cookie <cookie>] [--json].`,
3405
3618
  );
3406
3619
  }
3407
3620
  const packetPath = resolve(requireArg(args, "packet"));
3408
3621
  const baseUrl = requireArg(args, "base-url");
3409
- if (args.report === true) throw new Error("Missing value for --report");
3410
- if (args["auth-cookie"] === true) throw new Error("Missing value for --auth-cookie");
3622
+ if (args.report === true) throw refused("Missing value for --report");
3623
+ if (args["auth-cookie"] === true) throw refused("Missing value for --auth-cookie");
3411
3624
 
3412
3625
  const packet = readJson(packetPath);
3413
3626
  const workspace = resolveCampaignWorkspace(packetPath, {
@@ -3547,13 +3760,14 @@ function waiveOrRefuse(args, run, { gate = null, registeredGates = [] } = {}) {
3547
3760
  function checkpointCommand(args) {
3548
3761
  const subcommand = args._[1] || "help";
3549
3762
  if (subcommand !== "waive") {
3550
- throw new Error(`Unknown checkpoint subcommand. Use: ${cmd("checkpoint")} waive --packet <campaign-runtime.build.json> --gate <checkpoint-id> --reason "<why>" --waived-by "<named human>" [--expires-at <ISO>] [--review-condition "<trigger>"]. Registered gates: ${Object.keys(CHECKPOINT_EVALUATORS).join(", ")}.`);
3763
+ throw refused(`Unknown checkpoint subcommand. Use: ${cmd("checkpoint")} waive --packet <campaign-runtime.build.json> --gate <checkpoint-id> --reason "<why>" --waived-by "<named human>" [--expires-at <ISO>] [--review-condition "<trigger>"]. Registered gates: ${Object.keys(CHECKPOINT_EVALUATORS).join(", ")}.`);
3551
3764
  }
3552
3765
  return checkpointWaive(args);
3553
3766
  }
3554
3767
 
3555
3768
  export function checkpointWaive(args) {
3556
3769
  const packetPath = resolve(requireArg(args, "packet"));
3770
+ const dryRun = isDryRun(args);
3557
3771
  const gateId = requireArg(args, "gate").trim();
3558
3772
  const reason = requireArg(args, "reason");
3559
3773
  const waivedBy = requireArg(args, "waived-by");
@@ -3570,7 +3784,7 @@ export function checkpointWaive(args) {
3570
3784
 
3571
3785
  const doctor = doctorPacket(packetPath, { reportPath });
3572
3786
  let waiver = null;
3573
- commitAssemblyReport(workspace, (report) => {
3787
+ const recordWaiver = (report) => {
3574
3788
  const gate = evaluateCheckpointRegistry(CHECKPOINT_EVALUATORS, gateId, { doctor, packet, report });
3575
3789
  if (!gate) throw new Error(`Checkpoint gate "${gateId}" has no current evidence; repair the packet/spec/target and re-run doctor.`);
3576
3790
  if (gate.status !== "blocked") {
@@ -3591,10 +3805,30 @@ export function checkpointWaive(args) {
3591
3805
  `Checkpoint waiver: ${gateId} waived by ${waiver.waived_by} at ${waiver.waived_at}: ${waiver.reason}`,
3592
3806
  ];
3593
3807
  return updated;
3594
- }, {
3808
+ };
3809
+ // The gate registry decides waivability from the report, so both modes go
3810
+ // through the committing path itself (see commitWaiverToAssemblyReport): the
3811
+ // report is read and parsed the same way and the very same mutator runs over
3812
+ // it, so every refusal above fires exactly as it would for real. Only the
3813
+ // write and the doctor-sidecar stale stamp are skipped, and with them the
3814
+ // readiness block, which doctor can only read off a report on disk.
3815
+ commitWaiverToAssemblyReport(workspace, recordWaiver, {
3595
3816
  command: "checkpoint waive",
3596
3817
  staleReason: `A checkpoint waiver was recorded after this doctor snapshot. Re-run ${cmd("doctor")} (or next) for current state.`,
3597
- });
3818
+ }, { dryRun });
3819
+ if (dryRun) {
3820
+ return {
3821
+ ok: true,
3822
+ status: "dry_run",
3823
+ dry_run: true,
3824
+ action: "checkpoint-waive",
3825
+ gate: gateId,
3826
+ waiver,
3827
+ report_path: reportPath,
3828
+ would_write: reportPath,
3829
+ note: "Dry run: nothing was written and the doctor sidecar was not marked stale. Re-run without --dry-run to record this waiver.",
3830
+ };
3831
+ }
3598
3832
  return {
3599
3833
  ok: true,
3600
3834
  ...waiveReadiness(packetPath, reportPath),
@@ -4685,16 +4919,10 @@ export function pageKitSyncCommand(args) {
4685
4919
  const unknown = Object.keys(args).filter((key) => key !== "_" && !PAGE_KIT_SYNC_FLAGS.includes(key));
4686
4920
  if (unknown.length) {
4687
4921
  const valueHint = unknown.some((key) => key.includes("=")) ? " A flag takes its value as the next argument (--flag value), not --flag=value." : "";
4688
- throw new Error(`Unknown flag${unknown.length > 1 ? "s" : ""} for page-kit sync: ${unknown.map((key) => `--${key}`).join(", ")}.${valueHint} Known flags: ${PAGE_KIT_SYNC_FLAGS.map((key) => `--${key}`).join(", ")}.`);
4922
+ throw refused(`Unknown flag${unknown.length > 1 ? "s" : ""} for page-kit sync: ${unknown.map((key) => `--${key}`).join(", ")}.${valueHint} Known flags: ${PAGE_KIT_SYNC_FLAGS.map((key) => `--${key}`).join(", ")}.`);
4689
4923
  }
4690
4924
  const packetPath = resolve(requireArg(args, "packet"));
4691
- // `--dry-run` is a bare flag. The shared parser would read a following
4692
- // token as its value, so `--dry-run true` must fail rather than quietly
4693
- // become a real write.
4694
- if (Object.hasOwn(args, "dry-run") && args["dry-run"] !== true) {
4695
- throw new Error(`--dry-run takes no value (got ${JSON.stringify(args["dry-run"])}); write \`--dry-run\` on its own, after the other flags.`);
4696
- }
4697
- const dryRun = args["dry-run"] === true;
4925
+ const dryRun = isDryRun(args);
4698
4926
  const result = {
4699
4927
  ok: false,
4700
4928
  action: "page-kit sync",
@@ -4918,8 +5146,8 @@ export function pageKitSyncCommand(args) {
4918
5146
  // refereed by doctor. Exactly the derived fields are written; the rest of the
4919
5147
  // spec and every other file are untouched. The store-derived fields (the nine
4920
5148
  // campaign.store_* Store Profile fields, slice 2) join the write only behind
4921
- // --from-store <subdomain>, which reads the store's Admin API with the token
4922
- // named by --store-token-source env:<VAR> (default env:<SUBDOMAIN>_ADMIN_TOKEN);
5149
+ // --from-store <subdomain>, which reads the store's Admin API
5150
+ // through gateway login, or the explicit --store-token-source env:<VAR> break-glass path;
4923
5151
  // the default run stays offline. --dry-run prints the same diff and writes
4924
5152
  // nothing. Exit 2 when the packet, the spec or the target entry is missing,
4925
5153
  // or the store cannot be read.
@@ -4931,17 +5159,17 @@ const SPEC_DERIVE_FLAGS = Object.freeze(["packet", "dry-run", "json", "report",
4931
5159
  // mistake as an unknown flag: refused before anything is read).
4932
5160
  function parseSpecDeriveStoreFlags(args) {
4933
5161
  if (!Object.hasOwn(args, "from-store")) {
4934
- if (Object.hasOwn(args, "store-token-source")) throw new Error("--store-token-source only applies with --from-store <subdomain>.");
5162
+ if (Object.hasOwn(args, "store-token-source")) throw refused("--store-token-source only applies with --from-store <subdomain>.");
4935
5163
  return null;
4936
5164
  }
4937
5165
  const subdomain = normalizeStoreSubdomain(args["from-store"] === true ? "" : String(args["from-store"] ?? ""));
4938
5166
  if (!subdomain) {
4939
- throw new Error(`--from-store takes the store's subdomain (the <store> of <store>.29next.store), got ${JSON.stringify(args["from-store"] === true ? "" : args["from-store"])}.`);
5167
+ throw refused(`--from-store takes the store's subdomain (the <store> of <store>.29next.store), got ${JSON.stringify(args["from-store"] === true ? "" : args["from-store"])}.`);
4940
5168
  }
4941
- let tokenEnv = defaultStoreTokenEnvVar(subdomain);
5169
+ let tokenEnv = null;
4942
5170
  if (Object.hasOwn(args, "store-token-source")) {
4943
5171
  const parsed = parseStoreTokenSource(args["store-token-source"] === true ? "" : String(args["store-token-source"] ?? ""));
4944
- if (parsed.problem) throw new Error(`--store-token-source ${parsed.problem}`);
5172
+ if (parsed.problem) throw refused(`--store-token-source ${parsed.problem}`);
4945
5173
  tokenEnv = parsed.env;
4946
5174
  }
4947
5175
  return { subdomain, token_env: tokenEnv };
@@ -4950,23 +5178,22 @@ function parseSpecDeriveStoreFlags(args) {
4950
5178
  // `spec derive --from-store`: resolve the credential, read the store, then
4951
5179
  // run the same command with the store read in hand. The only network the
4952
5180
  // command ever does happens here, and the token never leaves this function:
4953
- // the result names the env var, not its value.
4954
- export async function specDeriveFromStoreCommand(args, { fetchImpl = globalThis.fetch, env = process.env } = {}) {
5181
+ // the result names the credential source, never its value.
5182
+ export async function specDeriveFromStoreCommand(args, { fetchImpl = globalThis.fetch, env = process.env, credentials, warn = console.warn } = {}) {
4955
5183
  const store = parseSpecDeriveStoreFlags(args);
4956
5184
  if (!store) return specDeriveCommand(args);
4957
- const token = typeof env[store.token_env] === "string" ? env[store.token_env].trim() : "";
4958
- if (!token) {
4959
- return specDeriveCommand(args, { store: { ...store, status: "credential_missing", detail: `${store.token_env} is not set (or empty) in the environment; export the store's Admin API access token there (Settings > API Access, scopes store:read and content:read), or name another variable with --store-token-source env:<VAR>.` } });
4960
- }
4961
- // Local preconditions first: a packet, spec or entry the command would
4962
- // refuse is refused before the token is sent anywhere. The preflight run
4963
- // stops at the store gate and writes nothing.
4964
5185
  const preflight = specDeriveCommand(args, { store: { ...store, status: "preflight" } });
4965
5186
  if (preflight.errors.length) return preflight;
4966
- const read = await readStoreProfile({ subdomain: store.subdomain, token, fetchImpl });
4967
- // The real run re-reads the packet; it must still be the campaign the
4968
- // preflight checked, or the store's profile lands in another campaign's
4969
- // spec.
5187
+ let read;
5188
+ if (store.token_env) {
5189
+ warn("Warning: explicit Admin environment credentials are a break-glass path. Use campaigns-os login --store <subdomain> and omit --store-token-source for supported gateway reads.");
5190
+ const token = typeof env[store.token_env] === "string" ? env[store.token_env].trim() : "";
5191
+ read = token ? await readStoreProfile({ subdomain: store.subdomain, token, fetchImpl }) : { status: "credential_missing", detail: `${store.token_env} is not set (or empty); use campaigns-os login --store ${store.subdomain}, or explicitly supply the break-glass environment credential.` };
5192
+ } else {
5193
+ const { readGatewayStoreProfile } = await import("./admin-transport.mjs");
5194
+ read = await readGatewayStoreProfile({ subdomain: store.subdomain, credentials, fetchImpl });
5195
+ }
5196
+ // Recheck the original packet/spec identity after the network operation.
4970
5197
  return specDeriveCommand(args, { store: { ...store, ...read, expected: { spec_path: preflight.spec_path, public_route_slug: preflight.public_route_slug } } });
4971
5198
  }
4972
5199
 
@@ -5019,7 +5246,7 @@ export function specDeriveCommand(args, { store: storeRead = null } = {}) {
5019
5246
  const unknown = Object.keys(args).filter((key) => key !== "_" && !SPEC_DERIVE_FLAGS.includes(key));
5020
5247
  if (unknown.length) {
5021
5248
  const valueHint = unknown.some((key) => key.includes("=")) ? " A flag takes its value as the next argument (--flag value), not --flag=value." : "";
5022
- throw new Error(`Unknown flag${unknown.length > 1 ? "s" : ""} for spec derive: ${unknown.map((key) => `--${key}`).join(", ")}.${valueHint} Known flags: ${SPEC_DERIVE_FLAGS.map((key) => `--${key}`).join(", ")}.`);
5249
+ throw refused(`Unknown flag${unknown.length > 1 ? "s" : ""} for spec derive: ${unknown.map((key) => `--${key}`).join(", ")}.${valueHint} Known flags: ${SPEC_DERIVE_FLAGS.map((key) => `--${key}`).join(", ")}.`);
5023
5250
  }
5024
5251
  // The store read is supplied by specDeriveFromStoreCommand; this function
5025
5252
  // never touches the network itself, so a --from-store call that reaches it
@@ -5027,13 +5254,8 @@ export function specDeriveCommand(args, { store: storeRead = null } = {}) {
5027
5254
  const storeFlags = parseSpecDeriveStoreFlags(args);
5028
5255
  if (storeFlags && !storeRead) throw new Error("spec derive --from-store must be dispatched through specDeriveFromStoreCommand (no store read was supplied).");
5029
5256
  const packetPath = resolve(requireArg(args, "packet"));
5030
- // `--dry-run` is a bare flag; `--dry-run true` must fail rather than
5031
- // quietly become a real write.
5032
- if (Object.hasOwn(args, "dry-run") && args["dry-run"] !== true) {
5033
- throw new Error(`--dry-run takes no value (got ${JSON.stringify(args["dry-run"])}); write \`--dry-run\` on its own, after the other flags.`);
5034
- }
5035
- if (args.report === true) throw new Error("Missing value for --report");
5036
- const dryRun = args["dry-run"] === true;
5257
+ const dryRun = isDryRun(args);
5258
+ if (args.report === true) throw refused("Missing value for --report");
5037
5259
  const result = {
5038
5260
  ok: false,
5039
5261
  action: "spec derive",
@@ -5053,7 +5275,7 @@ export function specDeriveCommand(args, { store: storeRead = null } = {}) {
5053
5275
  not_in_target: [],
5054
5276
  stale_hints: [],
5055
5277
  store: storeFlags
5056
- ? { subdomain: storeFlags.subdomain, admin_api: adminApiBaseForStore(storeFlags.subdomain), token_source: `env:${storeFlags.token_env}`, store_read: null, pages_read: null, primary_domain: null }
5278
+ ? { subdomain: storeFlags.subdomain, admin_api: adminApiBaseForStore(storeFlags.subdomain), ...(storeFlags.token_env ? {} : { transport: "gateway", endpoint: "https://mcp.nextcommerce.com/admin/" }), token_source: storeFlags.token_env ? `env:${storeFlags.token_env}` : "gateway:login", store_read: null, pages_read: null, primary_domain: null }
5057
5279
  : null,
5058
5280
  rebound: { build_context: null, assembly_report: null },
5059
5281
  errors: [],
@@ -5236,8 +5458,8 @@ export function specDeriveCommand(args, { store: storeRead = null } = {}) {
5236
5458
  return result;
5237
5459
  }
5238
5460
  if (storeRead && storeRead.status !== "ok") {
5239
- const codes = { credential_missing: "store_credential_missing", credential_invalid: "store_credential_invalid", unauthorized: "store_unauthorized", not_found: "store_not_found", unreachable: "store_unreachable", invalid: "store_response_invalid" };
5240
- addIssue(result.errors, `spec.derive.${codes[storeRead.status] || "store_unreachable"}`, `${storeRead.detail} Nothing was written.`, { subdomain: storeRead.subdomain, token_source: `env:${storeRead.token_env}` });
5461
+ const codes = { credential_unavailable: "store_credential_unavailable", credential_missing: "store_credential_missing", credential_invalid: "store_credential_invalid", unauthorized: "store_unauthorized", not_found: "store_not_found", unreachable: "store_unreachable", invalid: "store_response_invalid" };
5462
+ addIssue(result.errors, `spec.derive.${codes[storeRead.status] || "store_unreachable"}`, `${storeRead.detail} Nothing was written.`, { subdomain: storeRead.subdomain, token_source: storeRead.token_env ? `env:${storeRead.token_env}` : "gateway:login" });
5241
5463
  return result;
5242
5464
  }
5243
5465
  const plan = planSpecDerive({ spec, entry, pageFiles, packetBindings, waivedGates, waiversUnknown, publicRouteSlug });
@@ -5440,7 +5662,7 @@ export function specDeriveTextLines(result) {
5440
5662
  if (result.spec_path) lines.push(`Spec: ${singleLineField(result.spec_path)}`);
5441
5663
  if (result.campaigns_path) lines.push(`Target: ${singleLineField(result.campaigns_path)}[${result.public_route_slug || "<public-route-slug>"}]${result.page_tree ? `, page tree ${singleLineField(result.page_tree)}/` : ""}`);
5442
5664
  if (result.store) {
5443
- lines.push(`Store: ${singleLineField(result.store.admin_api)} (token ${singleLineField(result.store.token_source)}${result.store.primary_domain ? `, primary domain ${singleLineField(result.store.primary_domain)}` : ""}${result.store.pages_read && result.store.pages_read !== "ok" ? `, pages ${result.store.pages_read}` : ""})`);
5665
+ lines.push(`Store: ${singleLineField(result.store.endpoint || result.store.admin_api)} (token ${singleLineField(result.store.token_source)}${result.store.primary_domain ? `, primary domain ${singleLineField(result.store.primary_domain)}` : ""}${result.store.pages_read && result.store.pages_read !== "ok" ? `, pages ${result.store.pages_read}` : ""})`);
5444
5666
  }
5445
5667
  if (result.errors?.length) {
5446
5668
  lines.push("Errors:");
@@ -5481,9 +5703,9 @@ const PAGE_KIT_PARITY_FLAGS = Object.freeze(["packet", "json", "report"]);
5481
5703
  export function pageKitParityCommand(args, options = {}) {
5482
5704
  const unknown = Object.keys(args).filter((key) => key !== "_" && !PAGE_KIT_PARITY_FLAGS.includes(key));
5483
5705
  if (unknown.length) {
5484
- throw new Error(`Unknown flag${unknown.length > 1 ? "s" : ""} for page-kit parity: ${unknown.map((key) => `--${key}`).join(", ")}. Known flags: ${PAGE_KIT_PARITY_FLAGS.map((key) => `--${key}`).join(", ")}.`);
5706
+ throw refused(`Unknown flag${unknown.length > 1 ? "s" : ""} for page-kit parity: ${unknown.map((key) => `--${key}`).join(", ")}. Known flags: ${PAGE_KIT_PARITY_FLAGS.map((key) => `--${key}`).join(", ")}.`);
5485
5707
  }
5486
- if (args.report === true) throw new Error("Missing value for --report");
5708
+ if (args.report === true) throw refused("Missing value for --report");
5487
5709
  const packetPath = resolve(requireArg(args, "packet"));
5488
5710
  const result = {
5489
5711
  ok: false,
@@ -10784,7 +11006,7 @@ function resolveSkillInstallTargets(targetArg = null, platformArg = null) {
10784
11006
  : SKILL_PLATFORMS.filter((platform) => platform.id === requested);
10785
11007
 
10786
11008
  if (!selected.length) {
10787
- throw new Error(`Unknown --platform ${requested}. Use one of: ${skillPlatformHelp()}.`);
11009
+ throw refused(`Unknown --platform ${requested}. Use one of: ${skillPlatformHelp()}.`);
10788
11010
  }
10789
11011
 
10790
11012
  return selected.map((platform) => ({
@@ -10847,6 +11069,31 @@ function installSkills(targetArg = null, dryRun = false, platformArg = null) {
10847
11069
  };
10848
11070
  }
10849
11071
 
11072
+ // A platform directory counts as installed when a skill already sits under one
11073
+ // of the bundled names (current or not), or our own copy under a retired name. A
11074
+ // slot install-skills would only create says nothing about that platform, and
11075
+ // neither does a retired slot another skill occupies. A foreign skill under a
11076
+ // CURRENT bundled name reads as `updated` — install-skills would replace it —
11077
+ // so it does count; the refresh action is then what install-skills would do.
11078
+ const SKILL_ACTIONS_THAT_MARK_A_PLATFORM_INSTALLED = new Set(["unchanged", "updated", "retired"]);
11079
+
11080
+ export function scopeSkillStatusToInstalledPlatforms(status) {
11081
+ if (!Array.isArray(status?.targets)) return { status, scope: "requested", notInstalled: [] };
11082
+ const installedOn = (target) => (target.skills || []).some((skill) => SKILL_ACTIONS_THAT_MARK_A_PLATFORM_INSTALLED.has(skill?.action));
11083
+ const installed = status.targets.filter(installedOn);
11084
+ const describe = (target) => ({
11085
+ platform: target.platform,
11086
+ platform_label: target.platform_label,
11087
+ target_directory: target.target_directory,
11088
+ });
11089
+ if (!installed.length) return { status, scope: "no_platform_installed", notInstalled: status.targets.map(describe) };
11090
+ return {
11091
+ status: { ...status, targets: installed, skills: installed.flatMap((target) => target.skills) },
11092
+ scope: "installed_platforms",
11093
+ notInstalled: status.targets.filter((target) => !installedOn(target)).map(describe),
11094
+ };
11095
+ }
11096
+
10850
11097
  const TOOLING_ACTIONABLE_SKILL_ACTIONS = new Set(["created", "updated", "retired"]);
10851
11098
  const TOOLING_CLEAN_SKILL_ACTIONS = new Set(["unchanged"]);
10852
11099
 
@@ -10871,14 +11118,344 @@ function toolingSkillIdentity(skill) {
10871
11118
  return `${prefix}${skill?.name || "unknown skill"}`;
10872
11119
  }
10873
11120
 
11121
+ /**
11122
+ * `--skills-revision <value>`: the skills bundle identity an agent read, checked
11123
+ * against the bundle THIS CLI ships.
11124
+ *
11125
+ * The asymmetry is the whole point, and it is why the reported revision is named
11126
+ * `on_disk`. A skill's text is pulled into an agent's context once, at the start
11127
+ * of the task, and is never re-read; the CLI on disk, meanwhile, can be updated
11128
+ * underneath that session by an `npm install`, an `npx` cache refresh, or a
11129
+ * `git pull` in the checkout. So the only honest comparison is "what you are
11130
+ * still reading" against "what is installed right now", and the only honest
11131
+ * remedy for a mismatch is a fresh session — re-running the command cannot pull
11132
+ * the newer skill text into a context that already has the older one.
11133
+ *
11134
+ * The bundle spelling (`<package version>+skills.<n>`) is what every SKILL.md
11135
+ * states on its first body line. The `<skill-id>@<version>` spelling is a
11136
+ * fallback for an agent that carries only the frontmatter of the one skill it
11137
+ * loaded; it is checked against that skill's manifest entry. An id this bundle
11138
+ * does not ship is a mismatch, not a refusal: an agent quoting a skill that is
11139
+ * not here is reading text from some other bundle, which is exactly the
11140
+ * condition this flag exists to catch.
11141
+ */
11142
+ export function parseSkillsRevisionArg(value) {
11143
+ const text = String(value).trim();
11144
+ const at = text.lastIndexOf("@");
11145
+ if (at > 0 && at < text.length - 1) {
11146
+ return { spelling: "skill", id: text.slice(0, at), version: text.slice(at + 1), requested: text };
11147
+ }
11148
+ return { spelling: "bundle", requested: text };
11149
+ }
11150
+
11151
+ export function evaluateSkillsRevision(value, manifest) {
11152
+ const onDisk = typeof manifest?.bundle_revision === "string" ? manifest.bundle_revision : null;
11153
+ if (value === undefined) {
11154
+ return {
11155
+ status: "unchecked",
11156
+ requested: null,
11157
+ spelling: null,
11158
+ on_disk: onDisk,
11159
+ on_disk_skill: null,
11160
+ message: `unchecked (on disk ${onDisk || "unknown"})`,
11161
+ };
11162
+ }
11163
+ const parsed = parseSkillsRevisionArg(value);
11164
+ if (parsed.spelling === "skill") {
11165
+ const entry = (manifest?.skills || []).find((skill) => skill?.id === parsed.id) || null;
11166
+ const onDiskSkill = entry ? { id: entry.id, version: entry.version ?? null } : null;
11167
+ const match = Boolean(entry) && entry.version === parsed.version;
11168
+ return {
11169
+ status: match ? "match" : "mismatch",
11170
+ requested: parsed.requested,
11171
+ spelling: "skill",
11172
+ on_disk: onDisk,
11173
+ on_disk_skill: onDiskSkill,
11174
+ message: match
11175
+ ? `match (${parsed.requested}; bundle ${onDisk || "unknown"})`
11176
+ : `mismatch: loaded ${parsed.requested}, on disk ${
11177
+ onDiskSkill ? `${onDiskSkill.id}@${onDiskSkill.version}` : `no skill named ${parsed.id}`
11178
+ } (bundle ${onDisk || "unknown"}) — start a fresh session`,
11179
+ };
11180
+ }
11181
+ const match = Boolean(onDisk) && onDisk === parsed.requested;
11182
+ return {
11183
+ status: match ? "match" : "mismatch",
11184
+ requested: parsed.requested,
11185
+ spelling: "bundle",
11186
+ on_disk: onDisk,
11187
+ on_disk_skill: null,
11188
+ message: match
11189
+ ? `match (${onDisk})`
11190
+ : `mismatch: loaded ${parsed.requested}, on disk ${onDisk || "unknown"} — start a fresh session`,
11191
+ };
11192
+ }
11193
+
11194
+ export function skillsRevisionTextLines(result) {
11195
+ const revision = result?.skills_revision;
11196
+ return revision ? [`Skills revision: ${revision.message}`] : [];
11197
+ }
11198
+
11199
+ // The pin checks (ADR 0002, campaigns-os#466): one executable per project. The
11200
+ // project pin (an exact devDependency or dependency on this package) comes
11201
+ // first, the kernel version the Build Packet records second. Only an exact
11202
+ // version is a pin — a range or tag names no one executable, so it is reported
11203
+ // as `range` and the project counts as unpinned.
11204
+ const PIN_PACKAGE_NAME = "@nextcommerce/campaigns-os";
11205
+ const EXACT_VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
11206
+ // npm reads `=1.2.3` and `v1.2.3` as the exact version 1.2.3.
11207
+ const EXACT_PIN_SPEC_RE = /^=?v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$/;
11208
+ // peerDependencies and optionalDependencies install nothing this project runs.
11209
+ const PIN_KEYS = ["devDependencies", "dependencies"];
11210
+ const PIN_BLOCKING_STATUSES = new Set(["conflicting_pin", "stale_pin"]);
11211
+
11212
+ function exactPinVersion(spec) {
11213
+ return typeof spec === "string" ? EXACT_PIN_SPEC_RE.exec(spec)?.[1] ?? null : null;
11214
+ }
11215
+
11216
+ // An installed package's own manifest (node_modules/<name>/package.json or
11217
+ // node_modules/@<scope>/<name>/package.json) is never the project: run from
11218
+ // inside an install, the walk resolves the enclosing project as if the working
11219
+ // directory were that project. Any other manifest is a candidate, even one with
11220
+ // a node_modules segment higher up its path.
11221
+ function isInstalledPackageDir(dir) {
11222
+ const parent = dirname(dir);
11223
+ if (basename(parent) === "node_modules") return true;
11224
+ return basename(parent).startsWith("@") && basename(dirname(parent)) === "node_modules";
11225
+ }
11226
+
11227
+ function nearestPackageJson(startDir) {
11228
+ for (let dir = resolve(startDir); ; dir = dirname(dir)) {
11229
+ const candidate = join(dir, "package.json");
11230
+ if (!isInstalledPackageDir(dir) && existsSync(candidate) && statSync(candidate).isFile()) return candidate;
11231
+ if (dirname(dir) === dir) return null;
11232
+ }
11233
+ }
11234
+
11235
+ // npm's array form, or the `{ packages: [...] }` object form yarn also reads.
11236
+ function declaresWorkspaces(manifest) {
11237
+ const workspaces = manifest.workspaces;
11238
+ return Array.isArray(workspaces) || (isObject(workspaces) && Array.isArray(workspaces.packages));
11239
+ }
11240
+
11241
+ // An empty or whitespace-only spec pins nothing, so it counts as absent rather
11242
+ // than as a range.
11243
+ function pinSpecsIn(manifest) {
11244
+ return PIN_KEYS.flatMap((key) => {
11245
+ const spec = manifest?.[key]?.[PIN_PACKAGE_NAME];
11246
+ return typeof spec === "string" && spec.trim() ? [{ key, spec: spec.trim() }] : [];
11247
+ });
11248
+ }
11249
+
11250
+ // The project pin is the first exact spec on the walk up from the nearest
11251
+ // manifest, devDependencies before dependencies in each; a range is kept only
11252
+ // if nothing exact turns up. A manifest that names nothing is
11253
+ // neutral and walked through: it cannot supply a pin, so stopping there would
11254
+ // only hide one higher up. The walk ends after a workspace root, at the
11255
+ // filesystem root, and at a manifest it cannot read, with a warning, since
11256
+ // that one might have held the pin.
11257
+ function resolveProjectPin(packageJsonPath, warnings) {
11258
+ let range = null;
11259
+ for (let path = packageJsonPath; path; path = nearestPackageJson(dirname(dirname(path)))) {
11260
+ const problem = {};
11261
+ const manifest = readPinJson(path, problem);
11262
+ if (!manifest) {
11263
+ warnings.push(path === packageJsonPath
11264
+ ? `Project pin unavailable: ${path} ${problem.reason}.`
11265
+ : `Project pin walk stopped at ${path}: it ${problem.reason}.`);
11266
+ break;
11267
+ }
11268
+ const specs = pinSpecsIn(manifest);
11269
+ const exact = specs.find(({ spec }) => exactPinVersion(spec));
11270
+ if (exact) return { projectSpec: exact.spec, projectManifest: path, projectKey: exact.key };
11271
+ if (!range && specs.length) range = { projectSpec: specs[0].spec, projectManifest: path, projectKey: specs[0].key };
11272
+ if (declaresWorkspaces(manifest) || dirname(dirname(path)) === dirname(path)) break;
11273
+ }
11274
+ return range || { projectSpec: null, projectManifest: packageJsonPath, projectKey: null };
11275
+ }
11276
+
11277
+ // A malformed file is a missing source plus a warning, never a crash: the pin
11278
+ // line is one report among several and must not take the status down with it.
11279
+ // A leading BOM is valid to npm, so it is valid here.
11280
+ function readPinJson(path, problem = {}) {
11281
+ let text;
11282
+ try {
11283
+ text = readFileSync(path, "utf8");
11284
+ } catch (error) {
11285
+ problem.reason = `could not be read (${error.code || error.message})`;
11286
+ return null;
11287
+ }
11288
+ let value;
11289
+ try {
11290
+ value = JSON.parse(text.replace(/^\uFEFF/, ""));
11291
+ } catch {
11292
+ problem.reason = "is not valid JSON";
11293
+ return null;
11294
+ }
11295
+ if (isObject(value)) return value;
11296
+ problem.reason = "is not a JSON object";
11297
+ return null;
11298
+ }
11299
+
11300
+ /** The project and packet sources `tooling status` compares, read from disk. */
11301
+ export function resolvePinSources(args, { cwd = process.cwd() } = {}) {
11302
+ const warnings = [];
11303
+ const explicitPacket = optionalString(args.packet);
11304
+ let packetPath = explicitPacket ? resolve(cwd, explicitPacket) : null;
11305
+ let packet = null;
11306
+ const packetProblem = {};
11307
+ if (packetPath) {
11308
+ if (!existsSync(packetPath)) throw refused(`Build Packet not found: ${packetPath}`);
11309
+ packet = readPinJson(packetPath, packetProblem);
11310
+ }
11311
+ // An explicit packet names its project: its target repo, whatever the cwd.
11312
+ const projectStart = packet ? targetRepoFor(packetPath, packet) : cwd;
11313
+ const packageJsonPath = nearestPackageJson(projectStart);
11314
+ if (!packetPath) {
11315
+ // The contracted home of the packet is the project root beside package.json
11316
+ // (prepare-build's default --out), the same place readback discovers it.
11317
+ packetPath = join(packageJsonPath ? dirname(packageJsonPath) : resolve(cwd), "campaign-runtime.build.json");
11318
+ if (existsSync(packetPath)) packet = readPinJson(packetPath, packetProblem);
11319
+ }
11320
+
11321
+ const { projectSpec, projectManifest, projectKey } = resolveProjectPin(packageJsonPath, warnings);
11322
+
11323
+ let packetVersion = null;
11324
+ let packetVersionIgnored = null;
11325
+ if (packet && Object.hasOwn(packet, "campaigns_os_version")) {
11326
+ const recorded = packet.campaigns_os_version;
11327
+ if (typeof recorded === "string" && EXACT_VERSION_RE.test(recorded)) {
11328
+ packetVersion = recorded;
11329
+ } else {
11330
+ // Kept verbatim (a non-string as its JSON text) so the Pin: line can say
11331
+ // the field was there and ignored, not that it was absent.
11332
+ packetVersionIgnored = typeof recorded === "string" ? recorded : JSON.stringify(recorded);
11333
+ warnings.push(`Packet campaigns_os_version ignored: ${JSON.stringify(recorded)} in ${packetPath} is not an exact version.`);
11334
+ }
11335
+ } else if (existsSync(packetPath) && !packet) {
11336
+ warnings.push(`Packet pin unavailable: ${packetPath} ${packetProblem.reason}.`);
11337
+ }
11338
+ return {
11339
+ packageJsonPath,
11340
+ projectSpec,
11341
+ projectManifest,
11342
+ projectKey,
11343
+ packetPath: packet ? packetPath : null,
11344
+ packetVersion,
11345
+ packetVersionIgnored,
11346
+ warnings,
11347
+ };
11348
+ }
11349
+
11350
+ /**
11351
+ * Pure: the pin status from the two sources and the running version. The
11352
+ * message names where each version it quotes was read: the key and manifest of
11353
+ * the project pin, the packet file of the recorded version.
11354
+ */
11355
+ export function evaluatePin({ projectSpec = null, projectManifest = null, projectKey = null, packetVersion = null, packetVersionIgnored = null, packetPath = null, running, force = false }) {
11356
+ const projectVersion = exactPinVersion(projectSpec);
11357
+ const range = projectSpec != null && !projectVersion ? projectSpec : null;
11358
+ const source = projectVersion ? "project" : packetVersion ? "packet" : null;
11359
+ const version = projectVersion || packetVersion || null;
11360
+ const manifest = projectManifest || "package.json";
11361
+ const projectFrom = `${projectKey || "devDependencies"} in ${manifest}`;
11362
+ const packetFrom = `campaigns_os_version in ${packetPath || "the Build Packet"}`;
11363
+ const noPacket = packetVersionIgnored != null
11364
+ ? `campaigns_os_version ${JSON.stringify(packetVersionIgnored)} in ${packetPath || "the Build Packet"} is not a bare x.y.z version and was ignored`
11365
+ : packetPath ? `no campaigns_os_version in ${packetPath}` : "no packet version";
11366
+ let status;
11367
+ let message;
11368
+ if (projectVersion && packetVersion && projectVersion !== packetVersion) {
11369
+ status = "conflicting_pin";
11370
+ message = `conflicting_pin — project pins ${projectVersion} (${projectFrom}), packet records ${packetVersion} (${packetFrom})`;
11371
+ } else if (!version) {
11372
+ status = "unpinned";
11373
+ const project = range != null
11374
+ ? `project range ${range || '""'} (${projectFrom}) is not an exact version`
11375
+ : projectManifest ? `no project pin in ${projectManifest}` : "no package.json found";
11376
+ message = `unpinned (${project}; ${noPacket})`;
11377
+ } else if (version !== running) {
11378
+ status = "stale_pin";
11379
+ message = `stale_pin — ${source === "project" ? `project pins ${version} (${projectFrom})` : `packet records ${version} (${packetFrom})`}, running ${running}`;
11380
+ } else {
11381
+ status = "match";
11382
+ message = `match (${version} — ${source === "project" ? projectFrom : packetFrom})`;
11383
+ }
11384
+ const forced = force && PIN_BLOCKING_STATUSES.has(status);
11385
+ return {
11386
+ source,
11387
+ version,
11388
+ running,
11389
+ status,
11390
+ range,
11391
+ packet_version: packetVersion,
11392
+ packet_version_ignored: packetVersionIgnored,
11393
+ project_version: projectVersion,
11394
+ project_manifest: projectManifest,
11395
+ project_key: projectKey,
11396
+ forced,
11397
+ message: forced ? `${message} (overridden by --force)` : message,
11398
+ };
11399
+ }
11400
+
11401
+ // Each line names the manifest and key the pin (or range) was read from; with
11402
+ // neither, the nearest manifest and devDependencies, where the ADR puts a pin.
11403
+ function pinAction(pin, sources) {
11404
+ const manifest = pin.project_manifest || "the project's package.json";
11405
+ const key = pin.project_key || "devDependencies";
11406
+ const packet = sources.packetPath || "the Build Packet";
11407
+ if (pin.status === "conflicting_pin") {
11408
+ return `Align the project pin: set ${key}["${PIN_PACKAGE_NAME}"] in ${manifest} to ${pin.packet_version}, or re-run prepare-build with ${pin.project_version} so ${packet} records it. Pass --force to proceed anyway (recorded).`;
11409
+ }
11410
+ if (pin.source === "project") {
11411
+ return `Run the pinned executable (${LOCAL_INVOCATION_PREFIX} from the project), or move the pin: set ${key}["${PIN_PACKAGE_NAME}"] in ${manifest} to ${pin.running} and reinstall. Pass --force to proceed anyway (recorded).`;
11412
+ }
11413
+ const pinStep = pin.project_key
11414
+ ? `set ${key}["${PIN_PACKAGE_NAME}"] in ${manifest} to ${pin.running} (it holds the range ${JSON.stringify(pin.range)})`
11415
+ : `add "${PIN_PACKAGE_NAME}": "${pin.running}" to ${key} in ${manifest}`;
11416
+ return `Run the version ${packet} records (${pin.packet_version}), or pin the project: ${pinStep}; the next prepare-build re-stamps ${packet}. Pass --force to proceed anyway (recorded).`;
11417
+ }
11418
+
11419
+ export function pinTextLines(result) {
11420
+ return result?.pin ? [`Pin: ${result.pin.message}`] : [];
11421
+ }
11422
+
10874
11423
  function toolingCommand(args) {
10875
11424
  const action = args._[1] || "status";
10876
- if (action !== "status") throw new Error(`Unknown tooling command: ${action}`);
10877
- if (args.target === true) throw new Error("Missing value for --target");
10878
- if (args.platform === true) throw new Error("Missing value for --platform");
11425
+ if (action !== "status") throw refused(`Unknown tooling command: ${action}`);
11426
+ if (args.target === true) throw refused("Missing value for --target");
11427
+ if (args.platform === true) throw refused("Missing value for --platform");
11428
+ if (args["skills-revision"] === true) {
11429
+ // The example is the bundle this CLI ships, read from skills.json, so the
11430
+ // refusal never teaches a revision that has since moved.
11431
+ const shipped = readJson(join(ROOT, "skills.json")).bundle_revision;
11432
+ throw refused(`Missing value for --skills-revision. Pass the bundle revision the skill you loaded states on its first body line (for example --skills-revision ${shipped}), or that skill's <skill-id>@<version>.`);
11433
+ }
11434
+ // Bare, like --dry-run: the shared parser would read `--force true` as a
11435
+ // value, and an override must never hinge on how a token happened to parse.
11436
+ if (Object.hasOwn(args, "force") && args.force !== true) {
11437
+ throw refused(`--force takes no value (got ${JSON.stringify(args.force)}); write \`--force\` on its own.`);
11438
+ }
11439
+ // The parser keeps `--no-force` as its own key, so without this it would
11440
+ // run as if unsaid and the operator would never learn it did nothing.
11441
+ if (Object.hasOwn(args, "no-force")) {
11442
+ throw refused("--no-force is not a flag of tooling status; --force is bare and off by default");
11443
+ }
11444
+ if (args.packet === true) throw refused("Missing value for --packet");
11445
+ const pinSources = resolvePinSources(args);
10879
11446
 
10880
11447
  const pkg = readJson(join(ROOT, "package.json"));
10881
- const skillStatus = installSkills(args.target, true, args.platform || "all");
11448
+ // An explicit --target or --platform (`all` included) is checked as asked.
11449
+ // Without either, only the platform directories that already hold a
11450
+ // Campaigns OS skill are held to this bundle: the documented install is one
11451
+ // platform, and reading the other two as stale told that operator to install
11452
+ // everywhere and exit 2 on a correct setup.
11453
+ const explicitSkillScope = isNonEmptyString(args.target) || isNonEmptyString(args.platform);
11454
+ const allSkillStatus = installSkills(args.target, true, args.platform || "all");
11455
+ const skillScope = explicitSkillScope
11456
+ ? { status: allSkillStatus, scope: "requested", notInstalled: [] }
11457
+ : scopeSkillStatusToInstalledPlatforms(allSkillStatus);
11458
+ const skillStatus = skillScope.status;
10882
11459
  const skillActions = classifyToolingSkillActions(skillStatus.skills || []);
10883
11460
  const staleSkills = skillActions.actionable;
10884
11461
  const install = localInstallStatus(ROOT, pkg);
@@ -10950,9 +11527,30 @@ function toolingCommand(args) {
10950
11527
  warnings.push(`No pinned commit could be derived for this ${install.mode_label}; the package version is ${pkg.version || "unknown"}. Compare it against the commit you oriented on before relying on it.`);
10951
11528
  }
10952
11529
 
10953
- if (staleSkills.length) {
10954
- const skillArgs = args.target ? ["--target", args.target] : ["--platform", args.platform || "all"];
10955
- actions.push(`Refresh installed skills: ${cli.invocation_prefix} install-skills ${skillArgs.join(" ")}. Restart local agent sessions afterwards.`);
11530
+ if (skillScope.scope === "installed_platforms" && skillScope.notInstalled.length) {
11531
+ const checked = skillStatus.targets.map((target) => target.platform_label).join(", ");
11532
+ const skipped = skillScope.notInstalled.map((target) => target.platform_label);
11533
+ ready.push(`Skills checked for ${checked}; ${skipped.join(", ")} ${skipped.length === 1 ? "has" : "have"} no Campaigns OS skills installed and ${skipped.length === 1 ? "was" : "were"} not checked (pass --platform to check one).`);
11534
+ }
11535
+
11536
+ if (skillScope.scope === "no_platform_installed") {
11537
+ // Nothing to refresh: the documented install is one platform, the
11538
+ // harness in use, so name the choice rather than installing everywhere.
11539
+ // The command ends its own sentence and is runnable as printed (Claude
11540
+ // Code, the documented install). The other platforms follow in a separate
11541
+ // sentence of prose: no `<a|b>` template or parenthesis a shell would read
11542
+ // as a redirect or a subshell if the command were copied with it.
11543
+ actions.push(`Install bundled skills for the harness you use: ${cli.invocation_prefix} install-skills --platform claude. Use --platform codex for Codex, or --platform agents for shared agent skills such as Cursor's. Restart local agent sessions afterwards.`);
11544
+ } else if (staleSkills.length) {
11545
+ const stalePlatforms = SKILL_PLATFORMS.map((platform) => platform.id)
11546
+ .filter((id) => staleSkills.some((skill) => skill.platform === id));
11547
+ const invocations = args.target
11548
+ ? [["--target", args.target]]
11549
+ : skillScope.scope === "installed_platforms" && stalePlatforms.length < SKILL_PLATFORMS.length
11550
+ ? stalePlatforms.map((platform) => ["--platform", platform])
11551
+ : [["--platform", args.platform || "all"]];
11552
+ const commands = invocations.map((skillArgs) => `${cli.invocation_prefix} install-skills ${skillArgs.join(" ")}`);
11553
+ actions.push(`Refresh installed skills: ${commands.join(" and ")}. Restart local agent sessions afterwards.`);
10956
11554
  }
10957
11555
 
10958
11556
  if (install.mode === "checkout" && cli.global_binary.status === "not_found") {
@@ -10960,8 +11558,8 @@ function toolingCommand(args) {
10960
11558
  } else if (install.mode === "node_modules" && cli.global_binary.status !== "found") {
10961
11559
  // A consumer install runs through npm's bin resolution; no PATH ritual.
10962
11560
  warnings.push(cli.global_binary.status === "found_other_install"
10963
- ? `The campaigns-os on PATH (${cli.global_binary.path}) is a different install from the one inspected here (${install.location}); run commands as \`npx campaigns-os <command>\` from the folder that pins this toolkit so this copy runs.`
10964
- : `campaigns-os is not on PATH; run commands as \`npx campaigns-os <command>\` from the folder that pins this toolkit (npm resolves node_modules/.bin), or call node ${cli.local_bin} directly.`);
11561
+ ? `The campaigns-os on PATH (${cli.global_binary.path}) is a different install from the one inspected here (${install.location}); run commands as \`${LOCAL_INVOCATION_PREFIX} <command>\` from the folder that pins this toolkit so this copy runs.`
11562
+ : `campaigns-os is not on PATH; run commands as \`${LOCAL_INVOCATION_PREFIX} <command>\` from the folder that pins this toolkit (npm resolves node_modules/.bin), or call node ${cli.local_bin} directly.`);
10965
11563
  } else if (install.mode !== "checkout" && install.mode !== "npx_cache" && cli.global_binary.status === "not_found") {
10966
11564
  warnings.push(`campaigns-os is not on PATH; call node ${cli.local_bin} directly.`);
10967
11565
  } else if (install.mode !== "checkout" && cli.global_binary.status === "found_other_install") {
@@ -10980,11 +11578,43 @@ function toolingCommand(args) {
10980
11578
  warnings.push("This checkout has uncommitted changes; verify they are intentional before publishing or comparing freshness.");
10981
11579
  }
10982
11580
 
11581
+ // The manifest THIS CLI ships, read from its own package root — never from the
11582
+ // working directory, which may be a campaign repo with no skills.json at all.
11583
+ const skillsRevision = evaluateSkillsRevision(args["skills-revision"], readJsonIfExists(join(ROOT, "skills.json")) || {});
11584
+ if (skillsRevision.status === "mismatch") {
11585
+ actions.push(`Start a fresh session: the skills text you are reading is ${skillsRevision.requested}, and this CLI ships ${skillsRevision.on_disk || "an unknown bundle revision"}. Re-running this command cannot refresh skill text already in context.`);
11586
+ }
11587
+
11588
+ // The CLI's own package.json is the running version: the executable this
11589
+ // command is, not whichever one the project would have resolved.
11590
+ const pin = evaluatePin({
11591
+ projectSpec: pinSources.projectSpec,
11592
+ projectManifest: pinSources.projectManifest,
11593
+ projectKey: pinSources.projectKey,
11594
+ packetVersion: pinSources.packetVersion,
11595
+ packetVersionIgnored: pinSources.packetVersionIgnored,
11596
+ packetPath: pinSources.packetPath,
11597
+ running: pkg.version,
11598
+ force: args.force === true,
11599
+ });
11600
+ warnings.push(...pinSources.warnings);
11601
+ const pinBlocks = PIN_BLOCKING_STATUSES.has(pin.status) && !pin.forced;
11602
+ if (PIN_BLOCKING_STATUSES.has(pin.status)) {
11603
+ (pin.forced ? warnings : actions).push(pin.forced
11604
+ ? `Pin ${pin.status} overridden by --force; this run proceeds on ${pin.running}.`
11605
+ : pinAction(pin, pinSources));
11606
+ }
11607
+
10983
11608
  const gitBlocks = git.status === "ok" && Number.isFinite(git.behind) && git.behind > 0;
10984
- const ok = !gitBlocks && staleSkills.length === 0;
11609
+ const ok = !gitBlocks && staleSkills.length === 0 && skillsRevision.status !== "mismatch" && !pinBlocks;
10985
11610
  return {
10986
11611
  ok,
10987
11612
  status: ok ? "ready" : "attention_required",
11613
+ // A bare status string, so a consumer can branch on it without reaching into
11614
+ // an object; the detail sits beside it under skills_revision.
11615
+ revision_check: skillsRevision.status,
11616
+ skills_revision: skillsRevision,
11617
+ pin,
10988
11618
  install,
10989
11619
  package: packageStatus,
10990
11620
  git,
@@ -10992,6 +11622,12 @@ function toolingCommand(args) {
10992
11622
  skills: {
10993
11623
  ok: staleSkills.length === 0,
10994
11624
  stale_count: staleSkills.length,
11625
+ // requested: --target/--platform named the scope. installed_platforms:
11626
+ // only platforms with Campaigns OS skills installed were checked, and
11627
+ // not_installed_platforms lists the rest. no_platform_installed: none
11628
+ // had any, so every platform is listed there and was checked.
11629
+ scope: skillScope.scope,
11630
+ not_installed_platforms: skillScope.notInstalled,
10995
11631
  status: skillStatus,
10996
11632
  },
10997
11633
  ready,
@@ -11000,21 +11636,37 @@ function toolingCommand(args) {
11000
11636
  };
11001
11637
  }
11002
11638
 
11639
+ export async function toolingStatusCommand(args, options = {}) {
11640
+ const result = toolingCommand(args);
11641
+ const { gatewayLoginStatus } = await import("./admin-transport.mjs");
11642
+ result.gateway_login = await gatewayLoginStatus(options);
11643
+ const auth = result.gateway_login;
11644
+ if (!auth.accounts.length) result.warnings.push(auth.state === "unavailable"
11645
+ ? "Gateway credential storage is unavailable or busy. Check user credential directory permissions and keychain access; wait for another campaigns-os process to finish. See docs/gateway-login.md for interrupted-process recovery."
11646
+ : `Gateway login: ${auth.state}. Use ${result.cli.invocation_prefix} login --store <subdomain>.`);
11647
+ for (const account of auth.accounts) (account.state === "logged_in" ? result.ready : result.warnings).push(`Gateway login: ${account.state}; store ${account.store}; access remaining ${account.remaining_seconds}s; gateway ${auth.gateway}; reported version ${account.gateway_version || "unavailable"} (local credential metadata only).`);
11648
+ return result;
11649
+ }
11650
+
11003
11651
  export function toolingDiagnose(args, { runTooling = toolingCommand, runDoctor = doctorCommand } = {}) {
11004
11652
  let tooling = null;
11005
11653
  let doctor = null;
11006
11654
  let inspectionFailed = false;
11007
- const platform = args.platform || "all";
11008
11655
  // Inputs are used only by local producers, never echoed, and mutation flags
11009
11656
  // are not forwarded. Even an exception's message may contain a secret path.
11657
+ // An unnamed platform is not forwarded, so status checks only the platforms
11658
+ // that hold Campaigns OS skills, as it does when run directly.
11010
11659
  try {
11011
- tooling = runTooling({ _: ["tooling", "status"], platform, ...(typeof args.target === "string" ? { target: args.target } : {}) });
11660
+ tooling = runTooling({ _: ["tooling", "status"], ...(args.platform ? { platform: args.platform } : {}), ...(typeof args.target === "string" ? { target: args.target } : {}) });
11012
11661
  } catch { /* unavailable, no raw producer exception in a support export */ }
11013
11662
  if (args.packet !== undefined) {
11014
11663
  try {
11015
11664
  doctor = runDoctor({ packet: args.packet, "no-write": true, ...(typeof args.context === "string" ? { context: args.context } : {}), ...(typeof args.report === "string" ? { report: args.report } : {}) });
11016
11665
  } catch { inspectionFailed = true; }
11017
11666
  }
11667
+ // `installed` only when status actually narrowed to installed platforms; an
11668
+ // unnamed platform that checked every one (nothing installed) stays `all`.
11669
+ const platform = args.platform || (tooling?.skills?.scope === "installed_platforms" ? "installed" : "all");
11018
11670
  return diagnosticExport({ tooling, doctor, platform, inspectionFailed });
11019
11671
  }
11020
11672
 
@@ -11495,7 +12147,7 @@ async function findingsCommand(args, ambient = null) {
11495
12147
  if (sub === "harvest") return findingsHarvest(args, ambient);
11496
12148
  if (sub === "list") return findingsList(args, ambient);
11497
12149
  if (sub === "export") return findingsExport(args, ambient);
11498
- throw new Error(`Unknown findings subcommand "${sub}". Use: add | harvest | list | export.`);
12150
+ throw refused(`Unknown findings subcommand "${sub}". Use: add | harvest | list | export.`);
11499
12151
  }
11500
12152
 
11501
12153
  function resolveFindingsJournalPath(args, ambient = null) {
@@ -11539,7 +12191,7 @@ async function findingsAdd(args, ambient = null) {
11539
12191
  summary = answers.summary;
11540
12192
  details = answers.details;
11541
12193
  } else {
11542
- throw new Error(
12194
+ throw refused(
11543
12195
  `findings add is missing required flags: ${missing.join(", ")}. `
11544
12196
  + `Provide them as flags (flags-first for agents/CI), e.g. `
11545
12197
  + `--stage ${FINDING_STAGES[0]} --kind ${FINDING_KINDS[0]} --summary "..."`,
@@ -11773,7 +12425,7 @@ export async function runSessionCommand(args, ambient = null, sessionHolder = nu
11773
12425
  if (sub === "start") return runSessionStart(args);
11774
12426
  if (sub === "status") return runSessionStatus(args, ambient);
11775
12427
  if (sub === "end") return runSessionEnd(args, ambient, sessionHolder);
11776
- throw new Error(`Unknown run subcommand "${sub}". Use: start | end | status.`);
12428
+ throw refused(`Unknown run subcommand "${sub}". Use: start | end | status.`);
11777
12429
  }
11778
12430
 
11779
12431
  function writeRunSessionResult(result, args, exitCode) {
@@ -11987,10 +12639,14 @@ async function runSessionEnd(args, ambient = null, sessionHolder = null) {
11987
12639
  // `qa run`'s --base-url, --browser or --test-order say nothing about the
11988
12640
  // record — and run-record stamps the flag NAMES it was given into the Run
11989
12641
  // Record's argv_shape, so carrying them over would file them as run-record's.
12642
+ // `dry-run` is on the list for `run end --dry-run`, the one closer whose
12643
+ // invoking command implements the flag; a closer invoked by a command that
12644
+ // does not (the QA auto-end) drops it from extraArgs before calling — see
12645
+ // DRY_RUN_COMMANDS and autoEndRunSessionAfterTerminalQa.
11990
12646
  const RUN_RECORD_INHERITABLE_FLAGS = Object.freeze([
11991
12647
  "context", "report", "qa-verdict", "journal", "surfaces", "primary-surface", "surface-confidence",
11992
12648
  "agent-input-tokens", "agent-output-tokens", "agent-tool-output-tokens", "agent-total-tokens", "agent-elapsed-ms", "agent-model", "agent-usage-source",
11993
- "no-remit", "no-write", "proxy-base", "json",
12649
+ "no-remit", "no-write", "proxy-base", "dry-run", "json",
11994
12650
  ]);
11995
12651
 
11996
12652
  // The run-record argv that closes `session`: its run_id and journal, the
@@ -12019,7 +12675,10 @@ async function closeRunSession(found, { packet, extraArgs = {}, silent = false,
12019
12675
  const endArgs = runSessionEndArgs(found.session, packet, extraArgs);
12020
12676
  try {
12021
12677
  const summary = await runRecordCommand(endArgs, found, { silent, promptForConsent });
12022
- clearRunSession(found.path);
12678
+ // Clearing the session is a write like any other, so a closer carrying
12679
+ // --dry-run leaves it open: the operator sees the record the close would
12680
+ // assemble and can still close for real afterwards.
12681
+ if (endArgs["dry-run"] !== true) clearRunSession(found.path);
12023
12682
  return summary;
12024
12683
  } catch (error) {
12025
12684
  if (!onError) throw error;
@@ -12041,11 +12700,34 @@ async function closeRunSession(found, { packet, extraArgs = {}, silent = false,
12041
12700
  // forms. `run status` never sweeps — it is read-only.
12042
12701
  // Best-effort throughout: a closeout failure clears the file and says so on
12043
12702
  // stderr; it never blocks the command that triggered it.
12703
+ //
12704
+ // The sweep is an effect of the command that triggers it, and it runs BEFORE
12705
+ // dispatch — so it happens even when the argv that follows is refused. That is
12706
+ // deliberate (the stale session at the target is closed out either way), but it
12707
+ // makes the sweep the one place where `--no-write` could still write: it
12708
+ // assembles a Run Record and removes the session file. `--no-write` writes
12709
+ // nothing, the closeout included; see the guard below.
12044
12710
  const STALE_SWEEP_TARGET_COMMANDS = new Set(["start", "prepare-build", "build"]);
12045
12711
 
12046
12712
  async function closeOutStaleRunSessions(command, args) {
12047
12713
  // A command that opted out of sessions altogether must not sweep either.
12048
12714
  if (args["no-run-session"] === true) return [];
12715
+ // --no-write leaves the tree byte-identical. Inheriting the flag into the
12716
+ // closeout was not enough: it suppressed the Run Record but clearRunSession
12717
+ // still deleted the session file, so `--no-write` moved bytes. Skip the
12718
+ // sweep entirely instead — the stale session stays for the next run that
12719
+ // does write.
12720
+ if (args["no-write"] === true) return [];
12721
+ // Nor may a dry run sweep. The closeout writes a Run Record, deletes the
12722
+ // session file and (under consent) sends a remit — every effect --dry-run
12723
+ // promises not to have. The sweep runs BEFORE dispatch, so it was doing all
12724
+ // three for commands that implement the flag: `run end --dry-run --json`
12725
+ // over an aged session exited 0, wrote a record, POSTed once and removed the
12726
+ // session. --dry-run means "show me, do nothing"; the stale session simply
12727
+ // stays stale until a real invocation closes it. Gated on the same predicate
12728
+ // persistLifecycleIfRequested uses, so a stray --dry-run on a command that
12729
+ // does not implement it changes nothing here either.
12730
+ if (args["dry-run"] === true && commandImplementsDryRun(command, args)) return [];
12049
12731
  const roots = [];
12050
12732
  if (STALE_SWEEP_TARGET_COMMANDS.has(command) && optionalString(args.target)) roots.push(resolve(args.target));
12051
12733
  if (command === "run" && (args._[1] === "start" || args._[1] === "end")) {
@@ -12062,8 +12744,11 @@ async function closeOutStaleRunSessions(command, args) {
12062
12744
  }
12063
12745
  }
12064
12746
  // The closeout inherits the invoking command's remit controls: an explicit
12065
- // --no-remit / --no-write stays an opt-out, and a run pointed at a custom
12066
- // --proxy-base never remits the stale record to the canonical endpoint.
12747
+ // --no-remit stays an opt-out, and a run pointed at a custom --proxy-base
12748
+ // never remits the stale record to the canonical endpoint. --no-write is
12749
+ // carried too, though the guard above means it never arrives true: if the
12750
+ // sweep ever becomes conditional rather than skipped, the closeout must
12751
+ // still see it.
12067
12752
  const inherited = {};
12068
12753
  for (const flag of ["no-remit", "no-write", "proxy-base"]) {
12069
12754
  if (args[flag] !== undefined) inherited[flag] = args[flag];
@@ -12207,6 +12892,10 @@ export function describeCampaignKeyRejection(rejected) {
12207
12892
  // docs/workflow-findings-sidecar.md.
12208
12893
  async function runRecordCommand(args, ambient = null, { silent = false, promptForConsent = true } = {}) {
12209
12894
  const packetPath = resolve(requireArg(args, "packet"));
12895
+ // --dry-run assembles the record and shows it, then writes and sends
12896
+ // nothing. It differs from --no-write, which skips the assembly's reads
12897
+ // as well; combining the two is allowed and still writes nothing.
12898
+ const dryRun = isDryRun(args);
12210
12899
  const parsedSurfaces = parseRunRecordSurfaces(args.surfaces);
12211
12900
  const packet = readJson(packetPath);
12212
12901
  const explicitTargetRepo = resolveFromFile(packetPath, packet.assembly?.target_repo);
@@ -12307,6 +12996,7 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
12307
12996
  run_id_source: runIdSource,
12308
12997
  records,
12309
12998
  remit: { result: null, http_status: null, base_kind: null, sent: false, preserved: false },
12999
+ ...(dryRun ? { dry_run: true, would_write: null, would_remit: null } : {}),
12310
13000
  };
12311
13001
  if (silent) return summary;
12312
13002
  if (args.json) {
@@ -12373,7 +13063,11 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
12373
13063
  }
12374
13064
  if (existsSync(journalPath)) artifacts.push(runRecordArtifactRef("findings_journal", journalPath, WORKFLOW_FINDING_SCHEMA, baseDir));
12375
13065
 
12376
- const write = args["no-write"] !== true;
13066
+ // What a real run of this command line would write, and what this one does:
13067
+ // a dry run assembles and validates the record a real run would write
13068
+ // (assertRunRecordValid), and stops at the write itself (writeRunRecord).
13069
+ const wouldWrite = args["no-write"] !== true;
13070
+ const write = wouldWrite && !dryRun;
12377
13071
  // The verdict publish outcome this record carries: the session's attempt
12378
13072
  // for the verdict being recorded (the auto-end and `run end` both close
12379
13073
  // through here with the session still ambient), else the newest attempt
@@ -12388,8 +13082,10 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
12388
13082
  // refuses a second send, so a record it already has is final: it is neither
12389
13083
  // re-sent nor rewritten here. A prior send that did not land (failed,
12390
13084
  // pending) is retried when this run may send, and kept as it stands when it
12391
- // may not. A dry run reads nothing: it writes and sends nothing.
12392
- const prior = write ? readPriorRunRecord(runId, baseDir) : null;
13085
+ // may not. A pure --no-write run reads nothing: it writes and sends nothing.
13086
+ // A --dry-run does read it, because what a real run would do here is the
13087
+ // very thing the dry run is being asked to report.
13088
+ const prior = write || dryRun ? readPriorRunRecord(runId, baseDir) : null;
12393
13089
  const priorRemit = priorRemitOutcome(prior?.record);
12394
13090
  const storedRemotely = priorRemit?.state === "ok";
12395
13091
  // A pure local-inspection run (--no-write), an explicit --no-remit, or a
@@ -12410,6 +13106,16 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
12410
13106
  announceDefaultOnTelemetry(consent.scope || proxyBase);
12411
13107
  }
12412
13108
 
13109
+ // Under --dry-run the remit is disabled by construction (write is false), so
13110
+ // `remitDisabled` cannot say whether a real run would have sent. This does:
13111
+ // the same three stoppers minus the dry run itself, against the consent this
13112
+ // machine actually resolved.
13113
+ const wouldRemit = dryRun
13114
+ && args["no-remit"] !== true
13115
+ && args["no-write"] !== true
13116
+ && !storedRemotely
13117
+ && consent.state === "on";
13118
+
12413
13119
  // A publish the record already says landed is never downgraded by a
12414
13120
  // reassembly: the prior ok block wins over a session attempt that did not
12415
13121
  // land, mirroring the remit carry-forward below.
@@ -12446,9 +13152,6 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
12446
13152
  agentUsage,
12447
13153
  });
12448
13154
 
12449
- // Write before any network call so local capture does not depend on telemetry
12450
- // being fast or reachable. If a crash lands before the final rewrite below,
12451
- // the durable record is explicitly pending instead of silently skipped.
12452
13155
  const shouldAttemptRemit = !remitDisabled && consent.state === "on";
12453
13156
  const remitBaseKind = describeRemitBaseKind(proxyBase);
12454
13157
 
@@ -12467,6 +13170,9 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
12467
13170
  record: prior.record,
12468
13171
  run_id_source: runIdSource,
12469
13172
  remit: { result: REMIT_RESULTS.not_contacted, http_status: null, base_kind: null, sent: false, preserved: true },
13173
+ // A record the receiver already holds is final: a real run of this same
13174
+ // command line would write nothing and send nothing either.
13175
+ ...(dryRun ? { dry_run: true, would_write: null, would_remit: null } : {}),
12470
13176
  };
12471
13177
  if (silent) return summary;
12472
13178
  if (args.json) {
@@ -12504,7 +13210,11 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
12504
13210
  record.remit_base_kind = carriedForward.base_kind;
12505
13211
  }
12506
13212
 
12507
- const recordPath = write ? writeRunRecord(record, { baseDir }) : null;
13213
+ // The record is validated whenever a real run of this command line would
13214
+ // write one — under --dry-run too — and always BEFORE the remit below, so an
13215
+ // invalid record refuses ahead of any send on both paths. The write itself is
13216
+ // separate and happens after the remit; validating here does not write.
13217
+ if (wouldWrite) assertRunRecordValid(record);
12508
13218
 
12509
13219
  // Remit is consent-gated, non-fatal, bounded, and keyed on run_id — the
12510
13220
  // receiver holds one record per id and refuses a second POST for one it
@@ -12514,7 +13224,10 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
12514
13224
  // The key is only resolved when a send will actually be attempted: under
12515
13225
  // consent-off or --no-remit nothing goes out, so nothing is read and nothing
12516
13226
  // is said about a credential.
12517
- const keySource = shouldAttemptRemit ? resolveCampaignsApiKeySource(packet, packetPath, process.env) : { key: null, rejected: null };
13227
+ // A dry run resolves it too, and only when a real run would have: the
13228
+ // destination gate below names the credential that would travel, and the
13229
+ // preview must name the one the real send would.
13230
+ const keySource = shouldAttemptRemit || wouldRemit ? resolveCampaignsApiKeySource(packet, packetPath, process.env) : { key: null, rejected: null };
12518
13231
  const campaignKey = keySource.key;
12519
13232
  // A refused key is not a missing key. Say so on stderr, naming the source
12520
13233
  // and not the value, so the operator fixes the credential instead of reading
@@ -12524,7 +13237,30 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
12524
13237
  // send's outcome, which is only known below; the credential itself never
12525
13238
  // leaves the machine.
12526
13239
  const keyRejection = describeCampaignKeyRejection(keySource.rejected);
12527
- if (keyRejection) process.stderr.write(`[campaigns-os] run-record: ${keyRejection} This run's remit is attempted without a tenant scope.\n`);
13240
+ if (keyRejection) process.stderr.write(`[campaigns-os] run-record: ${keyRejection} ${dryRun ? "A real run's remit would be attempted without a tenant scope." : "This run's remit is attempted without a tenant scope."}\n`);
13241
+
13242
+ // The transport's own preconditions, run for a dry run as well: `remit`
13243
+ // demands a fetch to send with, and refuses a base that is not https (nor a
13244
+ // loopback host), BEFORE it opens a socket — so a send the real run could
13245
+ // never have made must not be previewed as one it would post. Same
13246
+ // functions, in the transport's order, with the same label and the same
13247
+ // credential wording the remit below hands them. The remit rail is non-fatal
13248
+ // by contract — the real run classifies either refusal as a failed remit and
13249
+ // still exits 0 — so the dry run reports it on the envelope and exits 0 too.
13250
+ let wouldRemitEndpoint = null;
13251
+ let wouldRemitRefusal = null;
13252
+ if (wouldRemit) {
13253
+ try {
13254
+ assertFetchAvailable(globalThis.fetch);
13255
+ const { base } = assertSecureProxyBase(proxyBase, {
13256
+ label: "Run Telemetry remit",
13257
+ credential: typeof campaignKey === "string" && campaignKey.trim() ? "the campaign key" : null,
13258
+ });
13259
+ wouldRemitEndpoint = `${base}${DEFAULT_RUNS_ENDPOINT}`;
13260
+ } catch (error) {
13261
+ wouldRemitRefusal = String(error?.message ?? error);
13262
+ }
13263
+ }
12528
13264
  const remitStatus = shouldAttemptRemit
12529
13265
  ? await remitRunRecord(record, { proxyBase, consent, campaignKey })
12530
13266
  : { attempted: false, ok: null, error: null, endpoint: null, result: null, http_status: null };
@@ -12543,7 +13279,11 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
12543
13279
  record.remit_base_kind = remitStatus.attempted ? remitBaseKind : null;
12544
13280
  }
12545
13281
 
12546
- if (write) writeRunRecord(record, { baseDir });
13282
+ // The record's one and only write, after the remit so the file that lands
13283
+ // carries this run's remit_* outcome rather than the placeholders it was
13284
+ // assembled with. Validated above, ahead of the send; nothing is written
13285
+ // under --dry-run or --no-write.
13286
+ const recordPath = write ? writeRunRecord(record, { baseDir }) : null;
12547
13287
 
12548
13288
  const summary = {
12549
13289
  ok: true,
@@ -12569,6 +13309,13 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
12569
13309
  sent: remitStatus.attempted,
12570
13310
  preserved: Boolean(carriedForward),
12571
13311
  },
13312
+ // What a real run of this same command line — the one without --dry-run —
13313
+ // would have done: the record file it would write, and the endpoint it
13314
+ // would POST to (null when --no-remit, --no-write or consent would have
13315
+ // stopped the send anyway, and null with `would_remit_refused` set when the
13316
+ // transport's destination gate refuses the base before any socket opens).
13317
+ // Envelope only; never on the record.
13318
+ ...(dryRun ? { dry_run: true, would_write: resolveRunRecordPath(runId, baseDir), would_remit: wouldRemitEndpoint, would_remit_refused: wouldRemitRefusal } : {}),
12572
13319
  };
12573
13320
  if (silent) return summary;
12574
13321
  if (args.json) {
@@ -12589,10 +13336,33 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
12589
13336
  console.log(`Remit: skipped (consent ${record.consent_state}${remitDisabled ? ", disabled for this run" : ""}).`);
12590
13337
  }
12591
13338
  if (write) console.log(`Wrote: ${recordPath}`);
12592
- else console.log("Dry run only (--no-write). No record written, no remit.");
13339
+ else if (dryRun) {
13340
+ console.log("Dry run (--dry-run). Nothing written, nothing sent.");
13341
+ console.log(`Would write: ${summary.would_write}`);
13342
+ console.log(`Would remit: ${summary.would_remit || (summary.would_remit_refused ? `nothing — the destination is refused before any request: ${summary.would_remit_refused}` : "nothing (remit is off for this run)")}`);
13343
+ } else console.log("Dry run only (--no-write). No record written, no remit.");
12593
13344
  return summary;
12594
13345
  }
12595
13346
 
13347
+ /**
13348
+ * The Run Record's refusal, lifted out of the write. `writeRunRecord` validates
13349
+ * the record before it writes and refuses an invalid one; that refusal belongs
13350
+ * to the command rather than to the write, because it has to fire on the dry
13351
+ * path (which never writes) and ahead of the remit on the real one (which
13352
+ * writes only afterwards). Both modes therefore refuse the same record with the
13353
+ * same message and the same exit code, before any send: the check fires here,
13354
+ * never on the identical one inside run-record.mjs (which stays as the writer's
13355
+ * own last guard). The validator is that writer's — imported, not re-stated —
13356
+ * so the two cannot disagree about what a valid record is. Writes nothing.
13357
+ */
13358
+ function assertRunRecordValid(record) {
13359
+ const validation = validateRunRecord(record);
13360
+ if (!validation.ok) {
13361
+ const detail = validation.errors.map((error) => `[${error.code}] ${error.message}`).join("; ");
13362
+ throw new Error(`Run Record failed validation; refusing to write: ${detail}`);
13363
+ }
13364
+ }
13365
+
12596
13366
  // The record already written under `runId` for this target, or null when there
12597
13367
  // is none, it cannot be parsed, or it is not a valid Run Record. Only a record
12598
13368
  // `writeRunRecord` could have written is trusted as a prior — the same
@@ -12733,7 +13503,7 @@ function parseRunRecordSurfaces(value) {
12733
13503
  const surfaces = parseCommaList(value);
12734
13504
  const unknown = surfaces.filter((surface) => !RUN_RECORD_SURFACES.includes(surface));
12735
13505
  if (unknown.length) {
12736
- throw new Error(`Unknown --surfaces value(s): ${unknown.join(", ")}. Use one of: ${RUN_RECORD_SURFACES.join(", ")}.`);
13506
+ throw refused(`Unknown --surfaces value(s): ${unknown.join(", ")}. Use one of: ${RUN_RECORD_SURFACES.join(", ")}.`);
12737
13507
  }
12738
13508
  return surfaces;
12739
13509
  }
@@ -12823,6 +13593,21 @@ function toolkitProvenance({ silent = false } = {}) {
12823
13593
  // canonical endpoint, or against --proxy-base when given, so it reports what
12824
13594
  // a remit to that endpoint would do. `off` takes no --proxy-base: an OFF
12825
13595
  // choice is machine-wide and the record it writes carries no scope.
13596
+ // `assertSecureProxyBase` is SHARED with the remit rail, where it runs in the
13597
+ // middle of a handler: `remit()` reaches it after the Run Record has been built
13598
+ // and written, and `spec derive --write-map` after the derive produced one. A
13599
+ // throw from those positions is a handler failure — the most valuable lifecycle
13600
+ // entry there is — so the refusal tag cannot live inside the validator.
13601
+ //
13602
+ // The call sites below are the up-front ones: the base comes straight off argv
13603
+ // and is checked before any configuration read or write, before a credential is
13604
+ // attached, and before a request. The tag therefore goes HERE, where the
13605
+ // position is known — `refusing()` is the shared form of that contract. The
13606
+ // message and the exit code stay the validator's own; only the verdict the
13607
+ // lifecycle journal reads is added.
13608
+ const refuseInsecureProxyBase = (proxyBase, options) =>
13609
+ refusing(() => assertSecureProxyBase(proxyBase, options));
13610
+
12826
13611
  async function telemetryCommand(args) {
12827
13612
  const sub = args._[1] || "status";
12828
13613
  const configPath = resolveConfigPath();
@@ -12831,17 +13616,17 @@ async function telemetryCommand(args) {
12831
13616
  // empty variable) is not "no flag": treating it as absent would grant or
12832
13617
  // check the canonical endpoint under a request that named something else.
12833
13618
  if (Object.hasOwn(args, "proxy-base") && !requestedBase) {
12834
- throw new Error(`telemetry ${sub}: --proxy-base needs a URL (https, or a loopback host); nothing was written.`);
13619
+ throw refused(`telemetry ${sub}: --proxy-base needs a URL (https, or a loopback host); nothing was written.`);
12835
13620
  }
12836
13621
  // Same transport rule as the remit rail: https, or a loopback host. A grant
12837
13622
  // for a base a remit would refuse to send to is not a grant, and a status
12838
13623
  // check against one would report on a remit that can never happen. Nothing
12839
13624
  // is sent here, so the in-clear warning is left to the remit.
12840
- const secureBase = () => assertSecureProxyBase(requestedBase, { label: `telemetry ${sub}`, warn: () => {} }).base;
13625
+ const secureBase = () => refuseInsecureProxyBase(requestedBase, { label: `telemetry ${sub}`, warn: () => {} }).base;
12841
13626
 
12842
13627
  if (sub === "on" || sub === "off") {
12843
13628
  if (sub === "off" && requestedBase) {
12844
- throw new Error(`telemetry off: --proxy-base is not accepted; turning telemetry off applies to every endpoint. To grant one endpoint instead, run: ${scopedConsentCommand(requestedBase)}`);
13629
+ throw refused(`telemetry off: --proxy-base is not accepted; turning telemetry off applies to every endpoint. To grant one endpoint instead, run: ${scopedConsentCommand(requestedBase)}`);
12845
13630
  }
12846
13631
  const proxyBase = requestedBase ? secureBase() : DEFAULT_PROXY_BASE;
12847
13632
  const { configPath: written, config } = writeConsentConfig(sub, { configPath, proxyBase, source: "telemetry-command" });
@@ -12917,7 +13702,7 @@ async function telemetryCommand(args) {
12917
13702
 
12918
13703
  if (sub === "list") return telemetryList(args);
12919
13704
 
12920
- throw new Error(`Unknown telemetry subcommand "${sub}". Use: status | on | off | list.`);
13705
+ throw refused(`Unknown telemetry subcommand "${sub}". Use: status | on | off | list.`);
12921
13706
  }
12922
13707
 
12923
13708
  // `telemetry list` — the reader that never existed. Since the receiver
@@ -12932,14 +13717,16 @@ const TELEMETRY_LIST_TIMEOUT_MS = 15_000;
12932
13717
 
12933
13718
  const TELEMETRY_LIST_MAX_BODY_BYTES = 4_000_000; // the receiver caps a listing at 500 summaries
12934
13719
 
12935
- async function telemetryList(args, { fetchImpl = globalThis.fetch } = {}) {
12936
- if (typeof fetchImpl !== "function") throw new Error("Global fetch is not available. Upgrade to Node 18+.");
13720
+ export async function telemetryList(args, { fetchImpl = globalThis.fetch } = {}) {
13721
+ if (typeof fetchImpl !== "function") throw refused("Global fetch is not available. Upgrade to Node 18+.");
12937
13722
  // Same transport gate the remit rail uses: https, or a loopback host with a
12938
- // loud warning that the credential is in clear. Anything else throws here,
12939
- // before a credential is attached to a request. The gate's own normalized
12940
- // base is what the consent scope and the request URL below are built from,
12941
- // so one string decides both.
12942
- const { url: proxyUrl, base: proxyBase, loopback } = assertSecureProxyBase(
13723
+ // loud warning that the credential is in clear. Anything else is refused
13724
+ // here, before a credential is attached to a request and before the packet
13725
+ // below is read — the same up-front position the gate holds under
13726
+ // `telemetry status|on`, so it is tagged the same way. The gate's own
13727
+ // normalized base is what the consent scope and the request URL below are
13728
+ // built from, so one string decides both.
13729
+ const { url: proxyUrl, base: proxyBase, loopback } = refuseInsecureProxyBase(
12943
13730
  optionalString(args["proxy-base"]) || DEFAULT_PROXY_BASE,
12944
13731
  { label: "telemetry list", credential: "the listing credential (the ops admin key, or the packet's campaign key)" },
12945
13732
  );
@@ -12951,9 +13738,11 @@ async function telemetryList(args, { fetchImpl = globalThis.fetch } = {}) {
12951
13738
  const { key, rejected } = resolveCampaignsApiKeySource(packet, packetPath, process.env);
12952
13739
  const rejection = describeCampaignKeyRejection(rejected);
12953
13740
  // Fail fast, before a request: a refused credential names its source so
12954
- // the operator can fix it, and nothing is sent in the meantime.
12955
- if (rejection) throw new Error(`telemetry list --packet: ${rejection}`);
12956
- if (!key) throw new Error(`telemetry list --packet: no Campaigns API key found in ${packetPath}, its local CampaignSpec, or the declared env source; pass a packet that carries one, or list cross-tenant with the admin key instead.`);
13741
+ // the operator can fix it, and nothing is sent in the meantime. Every
13742
+ // refusal ahead of the request is tagged, so the lifecycle journal records
13743
+ // nothing for it — the same rule as a flag refused up front.
13744
+ if (rejection) throw refused(`telemetry list --packet: ${rejection}`);
13745
+ if (!key) throw refused(`telemetry list --packet: no Campaigns API key found in ${packetPath}, its local CampaignSpec, or the declared env source; pass a packet that carries one, or list cross-tenant with the admin key instead.`);
12957
13746
  headers["X-Campaign-Key"] = key;
12958
13747
  scope = "tenant";
12959
13748
  } else {
@@ -12963,11 +13752,12 @@ async function telemetryList(args, { fetchImpl = globalThis.fetch } = {}) {
12963
13752
  // posture remit takes with default-on consent.
12964
13753
  const canonical = normalizeConsentScope(proxyBase) === CANONICAL_REMIT_SCOPE;
12965
13754
  if (!canonical && !loopback && args["trust-proxy-base"] !== true) {
12966
- throw new Error(`telemetry list: refusing to send the ops admin key to non-canonical ${proxyUrl.origin}. Pass --trust-proxy-base if that endpoint is yours, or use --packet for a tenant-scoped listing.`);
13755
+ throw refused(`telemetry list: refusing to send the ops admin key to non-canonical ${proxyUrl.origin}. Pass --trust-proxy-base if that endpoint is yours, or use --packet for a tenant-scoped listing.`);
12967
13756
  }
12968
13757
  const envName = optionalString(args["admin-key-env"]) || DEFAULT_ADMIN_KEY_ENV;
12969
13758
  const adminKey = process.env[envName];
12970
- if (!isNonEmptyString(adminKey)) throw new Error(`telemetry list: set ${envName} (the ops admin key) for the cross-tenant listing, or pass --packet <campaign-runtime.build.json> for a tenant-scoped one.`);
13759
+ if (!isNonEmptyString(adminKey)) throw refused(`telemetry list: set ${envName} (the ops admin key) for the cross-tenant listing, or pass --packet <campaign-runtime.build.json> for a tenant-scoped one.`);
13760
+ console.warn("Warning: CAMPAIGN_OPS_ADMIN_KEY (or the selected admin-key env) is a break-glass /api/runs listing credential. Use campaigns-os login for supported store-profile reads; login does not grant cross-tenant run listing.");
12971
13761
  headers["X-Campaigns-Ops-Admin-Key"] = adminKey.trim();
12972
13762
  scope = "admin";
12973
13763
  }
@@ -13291,7 +14081,8 @@ export function resultTextLines(result, { headerLines = [] } = {}) {
13291
14081
  // A waive command's second line names what it recorded; the third is the
13292
14082
  // stage doctor now picks for the report the waiver was written to.
13293
14083
  if (WAIVE_ACTIONS.has(result.action) && result.gate) {
13294
- lines.push(`Waived: ${result.gate} by ${result.waiver?.waived_by || "(unattributed)"}${result.waiver?.expires_at ? ` until ${result.waiver.expires_at}` : ""}`);
14084
+ lines.push(`${result.dry_run ? "Would waive" : "Waived"}: ${result.gate} by ${result.waiver?.waived_by || "(unattributed)"}${result.waiver?.expires_at ? ` until ${result.waiver.expires_at}` : ""}`);
14085
+ if (result.dry_run) lines.push(`Would write: ${result.would_write} (nothing was written)`);
13295
14086
  if (result.next_stage) lines.push(`Next stage: ${result.next_stage}${result.next_stage_reason ? ` (${result.next_stage_reason})` : ""}`);
13296
14087
  }
13297
14088
  if (result.targets?.length) {
@@ -13386,13 +14177,13 @@ export async function specDeriveWithMapWriteback(args, { fetchImpl = undefined,
13386
14177
  // by a stray value. `--proxy-base` names a URL or is refused here, before
13387
14178
  // anything is read, as `telemetry` refuses a bare one.
13388
14179
  if (Object.hasOwn(args, "write-map") && args["write-map"] !== true) {
13389
- throw new Error(`--write-map takes no value (got ${JSON.stringify(args["write-map"])}); write \`--write-map\` on its own, after the other flags.`);
14180
+ throw refused(`--write-map takes no value (got ${JSON.stringify(args["write-map"])}); write \`--write-map\` on its own, after the other flags.`);
13390
14181
  }
13391
14182
  if (Object.hasOwn(args, "proxy-base") && !optionalString(args["proxy-base"])) {
13392
- throw new Error("spec derive: --proxy-base needs a URL (https, or a loopback host); nothing was written.");
14183
+ throw refused("spec derive: --proxy-base needs a URL (https, or a loopback host); nothing was written.");
13393
14184
  }
13394
14185
  if (optionalString(args["proxy-base"]) && args["write-map"] !== true) {
13395
- throw new Error("spec derive: --proxy-base only applies with --write-map; nothing was written.");
14186
+ throw refused("spec derive: --proxy-base only applies with --write-map; nothing was written.");
13396
14187
  }
13397
14188
  const writeMap = args["write-map"] === true;
13398
14189
  const localArgs = Object.fromEntries(Object.entries(args).filter(([key]) => !SPEC_DERIVE_MAP_FLAGS.includes(key)));