@nextcommerce/campaigns-os 1.37.3 → 1.43.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/AGENTS.md +114 -10
  2. package/CHANGELOG.md +708 -0
  3. package/README.md +44 -31
  4. package/agents/claude/CLAUDE.md +5 -1
  5. package/campaign-spec/dist/types.d.ts +2 -0
  6. package/contracts/agent-relevant-change-policy.v1.json +11 -1
  7. package/contracts/effects.v1.json +4887 -0
  8. package/contracts/migration-sidecar-bundle.v0.json +9 -0
  9. package/contracts/release-ledger.json +1541 -0
  10. package/contracts/supported-surface.json +33 -12
  11. package/docs/build-packet.md +83 -22
  12. package/docs/campaigns-os-build-flow.md +2 -2
  13. package/docs/demo-preview.md +1 -1
  14. package/docs/diagnostics.md +7 -4
  15. package/docs/effects.md +350 -0
  16. package/docs/gateway-login.md +113 -0
  17. package/docs/local-setup.md +51 -0
  18. package/docs/migration-sidecar-bundle.md +6 -1
  19. package/docs/orientation-contract-reference.md +4 -1
  20. package/docs/progress-snapshots.md +9 -3
  21. package/docs/qa-and-test-orders.md +29 -13
  22. package/docs/readback.md +523 -0
  23. package/docs/runtime-readiness.md +1 -1
  24. package/docs/sdk-storage-compatibility.md +1 -1
  25. package/docs/skills-revision.md +364 -0
  26. package/docs/supported-surface.md +11 -3
  27. package/docs/versioning.md +8 -4
  28. package/package.json +10 -4
  29. package/schemas/campaign-runtime-assembly-report.v0.schema.json +6 -1
  30. package/schemas/campaign-runtime-build-packet.v0.schema.json +11 -1
  31. package/schemas/campaign-spec.v4.schema.json +4 -0
  32. package/schemas/campaigns-os-effects.v1.schema.json +211 -0
  33. package/schemas/campaigns-os-progress-snapshot.v0.schema.json +1 -0
  34. package/schemas/campaigns-os-qa-verdict-sidecar.v0.schema.json +1 -0
  35. package/schemas/campaigns-os-qa-verdict.v0.schema.json +1 -0
  36. package/schemas/campaigns-os-readback.v2.schema.json +267 -0
  37. package/schemas/campaigns-os-run-record.v0.schema.json +1 -0
  38. package/skills/campaign-lifecycle-orientation/SKILL.md +179 -0
  39. package/skills/campaign-readback-classification/SKILL.md +230 -0
  40. package/skills/campaign-run-evidence/SKILL.md +142 -0
  41. package/skills/contribution-intake/SKILL.md +85 -0
  42. package/skills/next-campaigns-build/SKILL.md +33 -12
  43. package/skills/next-campaigns-os/SKILL.md +59 -22
  44. package/skills/next-campaigns-os/references/session-intake.md +4 -4
  45. package/skills/next-campaigns-os-setup/SKILL.md +35 -14
  46. package/skills/next-campaigns-polish/SKILL.md +43 -17
  47. package/skills/next-campaigns-qa/SKILL.md +53 -28
  48. package/skills.json +40 -7
  49. package/src/admin-transport.mjs +123 -0
  50. package/src/cli.mjs +1178 -270
  51. package/src/credential-store.mjs +183 -0
  52. package/src/deviation.mjs +3 -2
  53. package/src/diagnostic.mjs +4 -1
  54. package/src/finding-cause.mjs +14 -10
  55. package/src/gate-actions.mjs +2 -2
  56. package/src/install-mode.mjs +17 -9
  57. package/src/lifecycle.mjs +96 -0
  58. package/src/login.mjs +152 -0
  59. package/src/package-install-fixture.mjs +3 -2
  60. package/src/polish-node.mjs +5 -2
  61. package/src/progress-node.mjs +3 -2
  62. package/src/progress.mjs +5 -3
  63. package/src/qa-node.mjs +105 -36
  64. package/src/qa-publish.mjs +112 -2
  65. package/src/qa-sidecar.mjs +2 -0
  66. package/src/qa-verdict-discovery.mjs +11 -0
  67. package/src/qa-verdict-publish.mjs +1 -0
  68. package/src/qa-verdict.mjs +8 -1
  69. package/src/readback.mjs +1937 -0
  70. package/src/remit.mjs +17 -3
  71. package/src/run-record-closeout.mjs +3 -4
  72. package/src/run-record.mjs +4 -0
  73. package/src/sidecar-bundle.mjs +21 -0
  74. package/src/spec-source-identity.mjs +44 -0
  75. package/src/stage-ledger.mjs +4 -1
  76. package/src/tooling-setup.mjs +160 -0
package/src/cli.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { campaignSpecIdentity, resolveCampaignIdentity, campaignIdentitiesMatch, localSpecIdentityFields } from "./spec-source-identity.mjs";
1
2
  import { withHtmlScanSnapshot, readHtmlScanText, htmlScanDigest } from "./html-scan.mjs";
2
3
  import { createHash, randomUUID } from "node:crypto";
3
4
  import { createDemo, demoArguments } from "./demo.mjs";
@@ -28,7 +29,10 @@ import { observeProgress, PROGRESS_OBSERVATION } from "./progress-node.mjs";
28
29
  import { describeSdkIgnoredMetaTags, isSdkIgnoredMetaTag } from "./sdk-meta-tags.mjs";
29
30
  import { HIDDEN_EAGER_MEDIA_ACTIONS, requiredActionText, substitutePacket } from "./gate-actions.mjs";
30
31
  import { ORDER_PATH_DEPTH_DRIFT_CODE, orderPathDepthDriftText, orderPathDepthReconcileAction, orderPathDepthsDisagree, parseOrderPathDepthFlag } from "./proof-policy.mjs";
31
- import { specMaterialHash } from "./spec-identity.mjs";
32
+ import { specMaterialHash, specHashesMatch } from "./spec-identity.mjs";
33
+ // The same predicate stage-ledger.mjs judges a mutator's result with, imported
34
+ // rather than re-stated so the waive preview and the commit agree by identity.
35
+ import { isPlainObject } from "./repo-scan.mjs";
32
36
  import { anyAssemblyReportStageBlocked, applyDerivedAssemblyReportSummary, commitAssemblyReport, QA_GATE_PLACEHOLDER_TEXT_RESIDUE, qaGatePassedForCurrentBuild, recordProducerStageOutcome } from "./stage-ledger.mjs";
33
37
  import { SESSION_ENDING_DISPOSITIONS, summarizePlaceholderTextGate, summarizePurchaseProof } from "./qa-verdict.mjs";
34
38
  import { assessRunRecordCloseout, identityMatches, latestMatchingRunRecord, reasonIsRemitRecovery } from "./run-record-closeout.mjs";
@@ -95,14 +99,19 @@ import { campaignSidecarPaths, resolveCampaignWorkspace, targetRepoFor } from ".
95
99
  import { canonicalPath, sameFile } from "./fs-identity.mjs";
96
100
  import { DEFAULT_PROXY_BASE, fetchSpecByMapId } from "./spec-fetch.mjs";
97
101
  import { writeMapSdkPin } from "./map-pin-writeback.mjs";
98
- 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 { qaVerdictIdentityMatch, discoverQaVerdicts, iterateQaVerdicts, qaVerdictCandidateScore, qaVerdictCandidateTime, qaVerdictPathHints } from "./qa-verdict-discovery.mjs";
103
+ import { assertFetchAvailable, assertSecureProxyBase, boundedResponseText, DEFAULT_RUNS_ENDPOINT, describeRemitBaseKind, isLoopbackHostname, REMIT_RESULTS, remitRunRecord } from "./remit.mjs";
100
104
  import {
101
105
  aggregateLifecycleForRun,
102
106
  appendLifecycleEntry,
103
107
  LIFECYCLE_JOURNAL_REL_PATH,
104
108
  NOOP_RECORDER,
105
109
  readLifecycleJournal,
110
+ REFUSED_INVOCATION,
111
+ refusalSeen,
112
+ refused,
113
+ refusing,
114
+ runWithRefusalScope,
106
115
  withCommandLifecycle,
107
116
  } from "./lifecycle.mjs";
108
117
  import {
@@ -139,7 +148,7 @@ import {
139
148
  formatStandardizationReportMarkdown,
140
149
  } from "./standardization-report.mjs";
141
150
  import { singleLineDetail, singleLineField } from "./text-safety.mjs";
142
- import { derivePackagePin, invocationPrefixFor, localInstallStatus, resolveInvocation } from "./install-mode.mjs";
151
+ import { derivePackagePin, invocationPrefixFor, LOCAL_INVOCATION_PREFIX, localInstallStatus, resolveInvocation } from "./install-mode.mjs";
143
152
  import {
144
153
  campaignRouteRoot,
145
154
  isAbsoluteHttpUrl,
@@ -292,7 +301,6 @@ import {
292
301
  } from "./spec-derive.mjs";
293
302
  import {
294
303
  adminApiBaseForStore,
295
- defaultStoreTokenEnvVar,
296
304
  normalizeStoreSubdomain,
297
305
  parseStoreTokenSource,
298
306
  planStoreProfileDerive,
@@ -316,10 +324,11 @@ export { derivePackagePin, localInstallStatus };
316
324
 
317
325
  // Every command this CLI PRODUCES for an operator or agent to copy is spelled
318
326
  // 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.
327
+ // `campaigns-os` from a checkout, `npx --no-install campaigns-os` from a
328
+ // campaign folder that pins the toolkit, `npx --yes <spec>` from an npx cache.
329
+ // Result payloads are never rewritten after the fact — a path, a quoted
330
+ // argument or a data value that happens to contain the words is left exactly
331
+ // as it is.
323
332
  function cmd(verb, rest = "") {
324
333
  const prefix = invocationPrefixFor(ROOT);
325
334
  return `${prefix} ${verb}${rest ? ` ${rest}` : ""}`;
@@ -467,18 +476,23 @@ Usage:
467
476
  campaigns-os standardize --target <campaign-repo> [--family <family>] [--slug <slug>] [--sdk-support-policy <path.json>] [--field-contract <path.json>] [--no-doctor] [--json]
468
477
  campaigns-os theme inspect --packet <campaign-runtime.build.json> [--context <json>] [--theme-policy <inspect_only|auto|off>] [--json]
469
478
  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
479
+ 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
480
+ 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
481
  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.
482
+ 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
483
  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
484
  campaigns-os polish capture --packet <campaign-runtime.build.json> --base-url <url> [--report <json>] [--headed] [--auth-cookie <cookie>] [--json]
485
+ 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.
486
+ campaigns-os readback --example [--json] # project the bundled synthetic sample; freshness is not computable for it by design
476
487
  campaigns-os validate-assembly-report --report <json> [--json]
477
488
  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
489
+ campaigns-os tooling setup --target <campaign-directory> [--platform claude] [--dry-run] [--json] # after installing the pinned project dependencies, install skills, connect Claude's context and install the QA browser; preserve existing pages and instructions, then restart the agent
490
+ campaigns-os login [--store <subdomain>]
491
+ campaigns-os logout [--store <subdomain>]
492
+ 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
493
  campaigns-os tooling diagnose [--packet <packet>] [--platform <claude|codex|agents|all>] [--json] # read-only redacted support summary
480
494
  campaigns-os install-agent-context --target <page-kit-dir> [--dry-run]
481
- 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
495
+ campaigns-os next [${NEXT_STAGE_ORDER.join("|")}] --packet <json> [--no-write] [--no-remit] [--proxy-base <url>] [--json] # no stage self-decides; returns gates[] + next_actions[] alongside the prompt
482
496
  campaigns-os next setup --packet <json> [--context <json>] [--report <json>] [--json]
483
497
  campaigns-os next build --packet <json> [--context <json>] [--report <json>] [--json]
484
498
  campaigns-os next polish --packet <json> --report <json> [--json]
@@ -487,21 +501,24 @@ Usage:
487
501
  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
502
  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
503
  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
504
+ 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
505
  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
506
  campaigns-os findings add --stage <stage> --kind <kind> --summary <text> [--details <text>] [--packet <json>] [--journal <path>] [--run-id <id>] [...context flags]
493
507
  campaigns-os findings harvest --packet <json> [--context <json>] [--report <json>] [--journal <path>] [--run-id <id>] [--write] [--json]
494
508
  campaigns-os findings list [--packet <json>] [--journal <path>] [--json]
495
509
  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]
510
+ 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
511
  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.
512
+ --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
513
 
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.
514
+ 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.
515
+ --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.
516
+ 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
517
  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
518
  campaigns-os telemetry off [--json] # turn remit off for every endpoint (takes no --proxy-base)
502
519
  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
520
  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
521
+ campaigns-os run status [--json] # active session + incomplete stages + deviation count + exact next command; read-only — it never sweeps and never journals
505
522
  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
523
 
507
524
  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 +552,15 @@ Examples:
535
552
  npm run campaigns-os -- standardize --target examples/target-page-kit --json
536
553
  `;
537
554
 
555
+ // `refused()`, its `REFUSED_INVOCATION` tag, and the `refusalSeen()` /
556
+ // `runWithRefusalScope()` accessors live in lifecycle.mjs — see the contract
557
+ // there.
558
+ // Command modules raise refusals too (`qa`'s unknown subcommand) and cli.mjs
559
+ // imports them, so the factory has to sit below both.
560
+
538
561
  // Top-level commands the CLI dispatches, used to offer a did-you-mean
539
562
  // suggestion on a typo instead of a bare "Unknown command". Derived from the
540
- // `command === "…"` literals in dispatch() itself (memoized on first use) so
563
+ // `command === "…"` literals in main() and dispatch() (memoized on first use) so
541
564
  // the list cannot drift as dispatch branches are added or removed. The regex
542
565
  // tolerates whitespace and either quote style so common reformats don't
543
566
  // silently empty the list; a known-commands test guards against a refactor
@@ -546,7 +569,7 @@ let knownCommandsCache = null;
546
569
  export function knownCommands() {
547
570
  if (knownCommandsCache) return knownCommandsCache;
548
571
  const found = new Set(["help"]);
549
- for (const match of dispatch.toString().matchAll(/command\s*===\s*["']([^"']+)["']/g)) {
572
+ for (const match of (main.toString() + dispatch.toString()).matchAll(/command\s*===\s*["']([^"']+)["']/g)) {
550
573
  found.add(match[1]);
551
574
  }
552
575
  knownCommandsCache = [...found];
@@ -593,7 +616,7 @@ function closestCommand(input) {
593
616
  return bestDistance <= budget ? best : null;
594
617
  }
595
618
 
596
- export async function main(argv) {
619
+ export async function main(argv, { authentication } = {}) {
597
620
  const args = parseArgs(argv);
598
621
  // `npx --yes -p <spec> campaigns-os <command>` and `npx --yes <spec>
599
622
  // campaigns-os <command>` both hand the bin its own name as the first
@@ -603,65 +626,100 @@ export async function main(argv) {
603
626
  if (args._[0] === "campaigns-os") args._.shift();
604
627
  const command = args._[0] || "help";
605
628
 
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
- }
629
+ // Everything below — dispatch and the onFinish that reads the verdict — runs
630
+ // inside ONE refusal scope, so a refusal raised by this invocation is visible
631
+ // only to this invocation's persistence step. Two main() calls interleaved
632
+ // in-process (a test, an embedding host) no longer share the verdict.
633
+ return runWithRefusalScope(async () => {
634
+ // Authentication never recovers/remits run sessions or records argv in a
635
+ // lifecycle journal. Credentials belong only in the user credential store.
636
+ if (command === "login" || command === "logout") {
637
+ const { runAuthentication } = await import("./login.mjs");
638
+ return runAuthentication(argv[0] === "campaigns-os" ? argv.slice(1) : argv, authentication);
639
+ }
640
+
641
+ // An offline sample must not recover sessions or emit lifecycle evidence.
642
+ if (command === "demo") {
643
+ // Validate raw tokens here: parsing loses duplicate flags. The private
644
+ // dispatcher then rechecks the parsed shape and extracts the target.
645
+ demoArguments(args, argv);
646
+ await dispatch(command, args);
647
+ return;
648
+ }
614
649
 
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
- }
650
+ // Diagnostic export is an inspection, including when a run is active or
651
+ // stale. Bypass session sweeping, ambient resolution, and lifecycle capture
652
+ // so no closeout/remit or journal write can occur before the projection.
653
+ if (command === "tooling" && args._[1] === "diagnose") {
654
+ const result = toolingDiagnose(args);
655
+ console.log(args.json ? JSON.stringify(result, null, 2) : diagnosticTextLines(result).join("\n"));
656
+ return;
657
+ }
623
658
 
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);
659
+ // Project setup must not recover campaign sessions, read gateway bindings,
660
+ // or emit lifecycle/telemetry evidence before a campaign is selected.
661
+ if (command === "tooling" && args._[1] === "setup") {
662
+ const { setupArguments, setupTooling, setupTextLines } = await import("./tooling-setup.mjs");
663
+ setupArguments(args, argv);
664
+ const { installQaBrowser } = await import("./qa-node.mjs");
665
+ const result = setupTooling(args, { packageRoot: ROOT, installSkills, installAgentContext, installBrowser: installQaBrowser });
666
+ console.log(args.json ? JSON.stringify(result, null, 2) : setupTextLines(result).join("\n"));
667
+ if (!result.ok) process.exitCode = 2;
668
+ return;
669
+ }
670
+
671
+ // Ambient run session (Tier 3): when `run start` is active, every command
672
+ // shares its run_id WITHOUT --run-id. Explicit --run-id still wins. Resolved
673
+ // ONCE here and threaded through dispatch + persistence so the run_id a
674
+ // command is tagged with and the journal it writes to come from a single
675
+ // read (no TOCTOU skew if the session changes mid-run).
676
+ //
677
+ // Before that read, close out any STALE session at the root this command is
678
+ // about to open a new one in. findRunSession ignores stale sessions so a new
679
+ // run never inherits an old run_id — but an ignored session was also an
680
+ // abandoned one: nine of them were found lingering with no Run Record and
681
+ // nothing remitted. Closing out is best-effort and never blocks the command.
682
+ const storageInspection = command === "sdk" && args._[1] === "storage-check";
683
+ // `readback` bypasses session resolution entirely, sweep included. Its
684
+ // `--packet` is a readback OVERRIDE naming the Build Packet to project, not a
685
+ // Build Packet to act on, and ambientRunSession treats that flag as a session
686
+ // locator: it read the named file whole through readJson, so a 40 MB packet
687
+ // was loaded into memory past readback's own 32 MiB bound before readback
688
+ // ever saw it, and a valid override exited 1 whenever some active session was
689
+ // bound to a different packet. Neither belongs to a command declared
690
+ // read-only. The lifecycle wrapper below still runs; the read-only exemption
691
+ // lives in persistLifecycleIfRequested, which writes no entry for readback.
692
+ const readOnlyProjection = command === "readback";
693
+ const sweptStale = storageInspection || readOnlyProjection ? [] : await closeOutStaleRunSessions(command, args);
694
+ const ambient = readOnlyProjection ? null : ambientRunSession(args);
695
+
696
+ // Wrap every command in the lifecycle instrumentation (T6): it captures the
697
+ // command, its argv shape, exit status, and timing. Re-throws unchanged so
698
+ // the CLI exit code is unaffected. Persistence runs via onFinish so it fires
699
+ // on BOTH the success and error paths — a command that THROWS (the most
700
+ // valuable failure telemetry) is recorded too, not just clean exits.
701
+ // Persistence is OPT-IN — an explicit --lifecycle-journal /
702
+ // CAMPAIGNS_OS_LIFECYCLE_LOG, or an active run session. With none, behavior
703
+ // is identical to before.
704
+ //
705
+ // `sessionHolder` is per-invocation, NOT module state: when start/
706
+ // prepare-build auto-open a run session mid-command, they publish it here
707
+ // so onFinish persists this command's own lifecycle entry into the new
708
+ // session — without two interleaved invocations ever sharing a session.
709
+ const sessionHolder = { current: ambient, autoStarted: false, adopted: false, qaResult: null, sweptStale };
710
+ await withCommandLifecycle(
711
+ {
712
+ command,
713
+ argvShape: argvShape(args),
714
+ runId: optionalString(args["run-id"]) || ambient?.session?.run_id || null,
715
+ onFinish: async (lifecycle, thrown) => {
716
+ persistLifecycleIfRequested(args, command, lifecycle, sessionHolder, thrown);
717
+ await autoEndRunSessionAfterTerminalQa(args, command, sessionHolder, thrown);
718
+ },
661
719
  },
662
- },
663
- (recorder) => dispatch(command, args, recorder, ambient, sessionHolder),
664
- );
720
+ (recorder) => dispatch(command, args, recorder, ambient, sessionHolder),
721
+ );
722
+ });
665
723
  }
666
724
 
667
725
  function ambientRunSession(args = {}) {
@@ -777,14 +835,72 @@ function resolveLifecycleJournal(args, { ambient = null, fallbackDir = null } =
777
835
  return fallbackDir ? join(resolve(fallbackDir), LIFECYCLE_JOURNAL_REL_PATH) : null;
778
836
  }
779
837
 
838
+ // The commands and subcommands that actually IMPLEMENT `--dry-run`. The flag
839
+ // reaches every handler through a permissive parseArgs, so it is silently
840
+ // accepted everywhere — and the lifecycle exemption below, scoped to the flag
841
+ // alone, therefore fired on commands that ignore it: `qa run --dry-run` placed
842
+ // orders while writing no journal entry, and (see runSessionEndArgs) carried
843
+ // the flag into its own auto-end, which assembled no Run Record and left the
844
+ // session open. Keyed by `command`, or `command <args._[1]>` where the flag
845
+ // belongs to one subcommand. A command outside this set given `--dry-run`
846
+ // behaves exactly as it did before: it journals if it otherwise would, and it
847
+ // is not refused — refusing unknown flags is separate work.
848
+ const DRY_RUN_COMMANDS = new Set([
849
+ "page-kit sync",
850
+ "spec derive",
851
+ "install-skills",
852
+ "install-agent-context",
853
+ "run-record",
854
+ "run end",
855
+ "qa publish",
856
+ "checkpoint waive",
857
+ "theme waive",
858
+ ]);
859
+
860
+ function commandImplementsDryRun(command, args = {}) {
861
+ return DRY_RUN_COMMANDS.has(command) || DRY_RUN_COMMANDS.has(`${command} ${args._?.[1]}`);
862
+ }
863
+
780
864
  // Append the command's lifecycle entry only when capture is active: an explicit
781
865
  // flag/env, or an ambient run session. Never throws — a lifecycle write must
782
866
  // not break a command (telemetry never blocks a build). `help` is a no-op
783
867
  // command and is not worth recording.
784
- function persistLifecycleIfRequested(args, command, lifecycle, sessionHolder) {
868
+ function persistLifecycleIfRequested(args, command, lifecycle, sessionHolder, thrown) {
785
869
  if (command === "help" || (command === "sdk" && args._[1] === "storage-check")) return;
870
+ // Three rules about what NEVER reaches the journal, whichever way the journal
871
+ // was selected (--lifecycle-journal, CAMPAIGNS_OS_LIFECYCLE_LOG, or an
872
+ // ambient run session). The in-process lifecycle object is still built; only
873
+ // the persistence below — the journal append and the deviation entry that
874
+ // follows it — is skipped, so a suppressed command still exits as before.
875
+ // 1. --no-write writes nothing, the journal included (issue #459: `run
876
+ // status --no-write` under an ambient session still created
877
+ // .campaign-runtime/command-lifecycle.jsonl).
878
+ // 2. A refused INVOCATION records nothing — an unknown top-level command,
879
+ // an unknown subcommand (`tooling statuss`), or a flag the command
880
+ // refuses up front (`standardize --dryrun`). None of them reached a
881
+ // handler, so a typo must not materialize a journal under the target.
882
+ // The tag the refusal carries IS the mechanism, read two ways: on the
883
+ // thrown error, or via refusalSeen() when the refusal was caught and
884
+ // rendered instead of thrown. There is deliberately no command-list
885
+ // backstop here — knownCommands() is regex-harvested and documented as
886
+ // fragile, so a second reading of it would be a second command list that
887
+ // could disagree with dispatch.
888
+ // 3. `run status` is read-only: it never sweeps and never journals.
889
+ if (args["no-write"] === true) return;
890
+ if (refusalSeen() || thrown?.code === REFUSED_INVOCATION) return;
891
+ if (command === "run" && args._[1] === "status") return;
786
892
  // 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;
893
+ // (--no-write is handled above, so only the read-only `doctor` form is left.)
894
+ if (command === "doctor" && args.packet && args.write !== true) return;
895
+ // The same rule, per-flag, for every command that takes `--dry-run`: the
896
+ // flag's whole promise is that the invocation writes nothing under the
897
+ // target, and the journal lives under the target. Only for the commands that
898
+ // make that promise, though — DRY_RUN_COMMANDS above.
899
+ if (args["dry-run"] === true && commandImplementsDryRun(command, args)) return;
900
+ // `readback` is declared read-only for the whole command, not per-flag: it
901
+ // writes nothing under the target, so a journal entry would be the one write
902
+ // its own contract forbids. Skipped the way doctor inspection is skipped.
903
+ if (command === "readback") return;
788
904
  const ambient = sessionHolder?.current || null;
789
905
  // A session auto-started DURING this command (start/prepare-build) is
790
906
  // published into sessionHolder by autoStartRunSession; this command's own
@@ -857,31 +973,38 @@ export function recordQaStageOutcome(args, result) {
857
973
  if (!existsSync(reportPath)) return false;
858
974
 
859
975
  const verdict = result.verdict;
976
+ const hasLocalIdentity = packet.spec?.local_spec_id != null || verdict?.local_spec_id != null;
977
+ if (hasLocalIdentity && !qaVerdictIdentityMatch(verdict, packet)) return false;
860
978
  const failed = (Array.isArray(verdict.assertions) ? verdict.assertions : [])
861
979
  .filter((assertion) => assertion?.status === "fail")
862
980
  .map((assertion) => `${assertion.id}: ${assertion.actual || "assertion failed"}`);
863
- const committed = commitAssemblyReport(workspace, (report) => recordProducerStageOutcome(report, {
864
- stage: "qa",
865
- disposition: verdict.disposition,
866
- timestamp: verdict.completed_at,
867
- command: `campaigns-os ${QA_RUN_PRODUCER}`,
868
- outputs: [result.local_path, result.qa_sidecar?.path].filter(isNonEmptyString),
869
- blockers: verdict.disposition === "blocked" ? failed : [],
870
- warnings: verdict.disposition === "ready_with_exceptions"
871
- ? ["QA completed with explicitly attributed exceptions; inspect the verdict artifact."]
872
- : [],
873
- // The producer knows its own run id and must restate it, or the stage
874
- // keeps a previous run's identity beside this run's status and outputs.
875
- identity: { verdict_run_id: optionalString(verdict.run_id) },
876
- // Which build this verdict judged, and the gates whose browser outcome
877
- // the doctor's static scan defers to (qaGatePassedForCurrentBuild). A
878
- // gate that never ran is left out, so silence never reads as a pass.
879
- evidence: qaStageGateEvidence(verdict, report),
880
- // Counts-only: never order ids, refs, emails or URLs (see
881
- // summarizePurchaseProof). This is what lets `next` tell a real purchase
882
- // path from a `--test-order off` diagnostic.
883
- proof: summarizePurchaseProof({ verdict, proofPolicy: packet.qa?.proof_policy }),
884
- }), {
981
+ const committed = commitAssemblyReport(workspace, (report) => {
982
+ if (hasLocalIdentity && !specHashesMatch(verdict.spec_hash, report.identity?.spec_material_hash)) {
983
+ throw new Error("Local-spec QA verdict belongs to a different material revision; report evidence was not changed.");
984
+ }
985
+ return recordProducerStageOutcome(report, {
986
+ stage: "qa",
987
+ disposition: verdict.disposition,
988
+ timestamp: verdict.completed_at,
989
+ command: `campaigns-os ${QA_RUN_PRODUCER}`,
990
+ outputs: [result.local_path, result.qa_sidecar?.path].filter(isNonEmptyString),
991
+ blockers: verdict.disposition === "blocked" ? failed : [],
992
+ warnings: verdict.disposition === "ready_with_exceptions"
993
+ ? ["QA completed with explicitly attributed exceptions; inspect the verdict artifact."]
994
+ : [],
995
+ // The producer knows its own run id and must restate it, or the stage
996
+ // keeps a previous run's identity beside this run's status and outputs.
997
+ identity: { verdict_run_id: optionalString(verdict.run_id) },
998
+ // Which build this verdict judged, and the gates whose browser outcome
999
+ // the doctor's static scan defers to (qaGatePassedForCurrentBuild). A
1000
+ // gate that never ran is left out, so silence never reads as a pass.
1001
+ evidence: qaStageGateEvidence(verdict, report),
1002
+ // Counts-only: never order ids, refs, emails or URLs (see
1003
+ // summarizePurchaseProof). This is what lets `next` tell a real purchase
1004
+ // path from a `--test-order off` diagnostic.
1005
+ proof: summarizePurchaseProof({ verdict, proofPolicy: packet.qa?.proof_policy }),
1006
+ });
1007
+ }, {
885
1008
  stage: "qa",
886
1009
  // The sidecar names this refresh as its producer (generated_by, #312).
887
1010
  // This function is the `qa run` stage record, whichever token dispatch
@@ -1009,9 +1132,18 @@ async function autoEndRunSessionAfterTerminalQa(args, command, sessionHolder, th
1009
1132
  return;
1010
1133
  }
1011
1134
 
1135
+ // `dry-run` is inheritable because `run end --dry-run` hands it to
1136
+ // run-record on purpose. The invoking command here is `qa run`, which does
1137
+ // not implement the flag, so inheriting it would turn its own auto-end into
1138
+ // a dry run: no Run Record written, and the session left open after a
1139
+ // terminal QA. Every other inheritable flag `qa run` may carry is one
1140
+ // run-record reads the same way whoever passed it.
1141
+ const extraArgs = { ...args, "qa-verdict": result.local_path };
1142
+ if (!commandImplementsDryRun(command, args)) delete extraArgs["dry-run"];
1143
+
1012
1144
  const summary = await closeRunSession(updatedFound, {
1013
1145
  packet,
1014
- extraArgs: { ...args, "qa-verdict": result.local_path },
1146
+ extraArgs,
1015
1147
  silent: true,
1016
1148
  promptForConsent: false,
1017
1149
  onError: (error) => process.stderr.write(`[campaigns-os] run session auto-end skipped after QA: ${error.message}\n`),
@@ -1056,6 +1188,23 @@ async function dispatch(command, args, recorder = NOOP_RECORDER, ambient = null,
1056
1188
  // them from this function's source.
1057
1189
  const mode = PREPARE_MODES[command];
1058
1190
  if (!mode) throw new Error(`No intake mode registered for "${command}"; add it to PREPARE_MODES.`);
1191
+ // Validate argv before --spec is inspected or --map-id fetches and caches.
1192
+ // Preserve resolveSpecPath's missing-input and map-id/target diagnostics.
1193
+ for (const flag of ["spec", "map-id", "source", "target", "source-kind", "proxy-base"]) {
1194
+ if (Object.hasOwn(args, flag)) requireArg(args, flag);
1195
+ }
1196
+ if (args.spec || (args["map-id"] && args.target)) requireArg(args, "source");
1197
+ if (args.spec) requireArg(args, "target");
1198
+ const sourceKind = optionalString(args["source-kind"], "html_funnel");
1199
+ if (sourceKind !== "html_funnel") {
1200
+ throw refused(`Unsupported source adapter "${sourceKind}". Use html_funnel for the current prepared-HTML flow.`);
1201
+ }
1202
+ if (Object.hasOwn(args, "wrapper-policy") && !isNonEmptyString(args["wrapper-policy"])) {
1203
+ throw refused(`--wrapper-policy needs a value. Accepted values: ${ADAPTER_WRAPPER_POLICIES.join(", ")}.`);
1204
+ }
1205
+ const wrapperPolicyFlag = refusing(() => parseWrapperPolicyFlag(args));
1206
+ refusing(() => requireDesignManifestValue(args));
1207
+ const orderPathDepthFlag = refusing(() => parseOrderPathDepthFlag(args, { command: "prepare-build" }));
1059
1208
  // Tier 2: mark sub-phases so the lifecycle journal entry carries per-phase
1060
1209
  // timings (spec resolve vs the prepare+doctor+install build), which Tier 1
1061
1210
  // aggregates into `start:resolve-spec` / `start:prepare-build` stages.
@@ -1067,7 +1216,7 @@ async function dispatch(command, args, recorder = NOOP_RECORDER, ambient = null,
1067
1216
  args.spec = resolved.specPath;
1068
1217
  // `command` rides along for the doctor sidecar's generated_by stamp when
1069
1218
  // the mode runs doctor (#312): threaded from here, not re-read from argv.
1070
- const result = await recorder.time("prepare-build", () => prepareBuild(args, { ...mode, command, specInput }));
1219
+ const result = await recorder.time("prepare-build", () => prepareBuild(args, { ...mode, command, specInput, sourceKind, wrapperPolicyFlag, orderPathDepthFlag }));
1071
1220
  result.spec_source = resolved;
1072
1221
  autoStartRunSession(result, args, ambient, sessionHolder);
1073
1222
  printPrepareResult(result, args);
@@ -1083,7 +1232,7 @@ async function dispatch(command, args, recorder = NOOP_RECORDER, ambient = null,
1083
1232
 
1084
1233
  if (command === "bundle") {
1085
1234
  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].');
1235
+ if (subcommand !== "check") throw refused('Unknown bundle subcommand. Use: campaigns-os bundle check --packet <campaign-runtime.build.json> [--require-qa] [--json].');
1087
1236
  const { inspectSidecarBundle, sidecarBundleReadinessLine } = await import("./sidecar-bundle.mjs");
1088
1237
  const result = inspectSidecarBundle({
1089
1238
  packetPath: requireArg(args, "packet"),
@@ -1097,10 +1246,10 @@ async function dispatch(command, args, recorder = NOOP_RECORDER, ambient = null,
1097
1246
  }
1098
1247
 
1099
1248
  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].");
1249
+ 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
1250
  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}`);
1251
+ if (args.json !== undefined && args.json !== true) throw refused("--json is a boolean flag and takes no value.");
1252
+ for (const key of Object.keys(args)) if (!known.has(key)) throw refused(`Unknown SDK storage-check flag: --${key}`);
1104
1253
  const { scanSdkStorageCompatibility, formatStorageCompatibilityReport } = await import("./sdk-storage-compatibility.mjs");
1105
1254
  const result = scanSdkStorageCompatibility({
1106
1255
  cwd: requireArg(args, "target"),
@@ -1145,6 +1294,21 @@ async function dispatch(command, args, recorder = NOOP_RECORDER, ambient = null,
1145
1294
  return;
1146
1295
  }
1147
1296
 
1297
+ if (command === "readback") {
1298
+ // Read-only projection over a target's already-emitted artifacts. It owns
1299
+ // its own exit codes (0 for any projection it could form, 2 for a request
1300
+ // that cannot form one) rather than throwing, so a usage error reads as a
1301
+ // one-line refusal instead of a stack-shaped CLI error.
1302
+ const { runReadbackCommand } = await import("./readback.mjs");
1303
+ const { exitCode, text } = runReadbackCommand(args);
1304
+ if (exitCode === 0) process.stdout.write(text);
1305
+ else {
1306
+ process.stderr.write(text);
1307
+ process.exitCode = 2;
1308
+ }
1309
+ return;
1310
+ }
1311
+
1148
1312
  if (command === "validate-assembly-report") {
1149
1313
  const reportPath = requireArg(args, "report");
1150
1314
  const result = validateAssemblyReport(readJson(resolve(reportPath)));
@@ -1160,8 +1324,8 @@ async function dispatch(command, args, recorder = NOOP_RECORDER, ambient = null,
1160
1324
  }
1161
1325
 
1162
1326
  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");
1327
+ if (args.target === true) throw refused("Missing value for --target");
1328
+ if (args.platform === true) throw refused("Missing value for --platform");
1165
1329
  const result = installSkills(args.target, Boolean(args["dry-run"]), args.platform);
1166
1330
  writeResult(result, args, 0);
1167
1331
  return;
@@ -1172,7 +1336,7 @@ async function dispatch(command, args, recorder = NOOP_RECORDER, ambient = null,
1172
1336
  // Spelled as inequalities: knownCommands() harvests the top-level
1173
1337
  // command literals from this function by an equality pattern that a
1174
1338
  // 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].");
1339
+ 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
1340
  const parity = subcommand !== "sync";
1177
1341
  const result = parity ? pageKitParityCommand(args) : pageKitSyncCommand(args);
1178
1342
  if (args.json) console.log(JSON.stringify(result, null, 2));
@@ -1185,7 +1349,7 @@ async function dispatch(command, args, recorder = NOOP_RECORDER, ambient = null,
1185
1349
  const subcommand = args._[1] || null;
1186
1350
  // Inequality on purpose: knownCommands() harvests top-level command
1187
1351
  // 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].");
1352
+ if (subcommand !== "derive") throw refused("Unknown spec subcommand. Use: campaigns-os spec derive --packet <campaign-runtime.build.json> [--dry-run] [--json].");
1189
1353
  const result = await specDeriveWithMapWriteback(args);
1190
1354
  if (args.json) console.log(JSON.stringify(result, null, 2));
1191
1355
  else for (const line of specDeriveWriteMapTextLines(result)) console.log(line);
@@ -1194,8 +1358,11 @@ async function dispatch(command, args, recorder = NOOP_RECORDER, ambient = null,
1194
1358
  }
1195
1359
 
1196
1360
  if (command === "tooling") {
1197
- const result = toolingCommand(args);
1198
- writeResult(result, args, result.ok ? 0 : 2);
1361
+ const result = await toolingStatusCommand(args);
1362
+ // The revision line is a header, so a mismatch is stated before the status
1363
+ // an operator would otherwise read as fine — and the full status still
1364
+ // prints, because the exit code is set after the render, not instead of it.
1365
+ writeResult(result, args, result.ok ? 0 : 2, { headerLines: [...skillsRevisionTextLines(result), ...pinTextLines(result)] });
1199
1366
  return;
1200
1367
  }
1201
1368
 
@@ -1217,7 +1384,9 @@ async function dispatch(command, args, recorder = NOOP_RECORDER, ambient = null,
1217
1384
  // command a QA run prints has to agree with the run_id this session will
1218
1385
  // later close and remit under.
1219
1386
  const result = await runQaCli(args, { ambient });
1220
- if (args._[1] === "run" && result?.verdict && recordQaStageOutcome(args, result)) {
1387
+ // nextStage requires a packet. Guard its optional, swallowed progress
1388
+ // probe explicitly so it cannot construct a refusal in that try block.
1389
+ if (args._[1] === "run" && result?.verdict && isNonEmptyString(args.packet) && recordQaStageOutcome(args, result)) {
1221
1390
  // Observe committed QA before the existing closeout; this cannot close
1222
1391
  // a run or change the QA disposition. Reuse the canonical picker.
1223
1392
  try {
@@ -1235,6 +1404,9 @@ async function dispatch(command, args, recorder = NOOP_RECORDER, ambient = null,
1235
1404
  }
1236
1405
 
1237
1406
  if (command === "run-record") {
1407
+ // These are the operator's own argv. Internal closeouts call
1408
+ // runRecordCommand directly and retain their pre-1.43.1 flag handling.
1409
+ validateRunRecordArgv(args);
1238
1410
  await runRecordCommand(args, ambient);
1239
1411
  return;
1240
1412
  }
@@ -1252,7 +1424,7 @@ async function dispatch(command, args, recorder = NOOP_RECORDER, ambient = null,
1252
1424
 
1253
1425
  const suggestion = closestCommand(command);
1254
1426
  const didYouMean = suggestion ? ` Did you mean "${suggestion}"?` : "";
1255
- throw new Error(
1427
+ throw refused(
1256
1428
  `Unknown command: ${command}.${didYouMean} Run \`campaigns-os --help\` to see available commands.`,
1257
1429
  );
1258
1430
  }
@@ -1277,12 +1449,25 @@ function parseArgs(argv) {
1277
1449
  return args;
1278
1450
  }
1279
1451
 
1452
+ // The shared "missing required flag" refusal. Every call site resolves flags
1453
+ // at the top of its handler, before the command reads or writes anything, so
1454
+ // this is always an up-front refusal and carries the tag.
1280
1455
  function requireArg(args, key) {
1281
1456
  const value = args[key];
1282
- if (!isNonEmptyString(value)) throw new Error(`Missing required --${key}`);
1457
+ if (!isNonEmptyString(value)) throw refused(`Missing required --${key}`);
1283
1458
  return value;
1284
1459
  }
1285
1460
 
1461
+ // `--dry-run` is a bare flag on every command that takes one. The shared
1462
+ // parser would read a following token as its value, so `--dry-run true` must
1463
+ // fail rather than quietly become a real write or a real send.
1464
+ function isDryRun(args) {
1465
+ if (Object.hasOwn(args, "dry-run") && args["dry-run"] !== true) {
1466
+ throw refused(`--dry-run takes no value (got ${JSON.stringify(args["dry-run"])}); write \`--dry-run\` on its own, after the other flags.`);
1467
+ }
1468
+ return args["dry-run"] === true;
1469
+ }
1470
+
1286
1471
  function isObject(value) {
1287
1472
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
1288
1473
  }
@@ -1355,7 +1540,7 @@ async function resolveSpecPath(args, opts = {}) {
1355
1540
  const mapId = String(args["map-id"]).trim();
1356
1541
  const targetRepo = opts.targetRepo || (args.target ? resolve(args.target) : null);
1357
1542
  if (!targetRepo) {
1358
- throw new Error("--map-id requires --target (so the fetched spec can be cached under <target>/.campaign-runtime/).");
1543
+ throw refused("--map-id requires --target (so the fetched spec can be cached under <target>/.campaign-runtime/).");
1359
1544
  }
1360
1545
  const proxyBase = optionalString(args["proxy-base"], DEFAULT_PROXY_BASE);
1361
1546
  const cacheDir = join(targetRepo, ".campaign-runtime", "fetched-specs");
@@ -1374,7 +1559,7 @@ async function resolveSpecPath(args, opts = {}) {
1374
1559
  algorithm: "map-store-v1", local_spec_material_hash: specMaterialHash(spec) },
1375
1560
  };
1376
1561
  }
1377
- throw new Error(
1562
+ throw refused(
1378
1563
  "Either --spec <path> or --map-id <id> is required. " +
1379
1564
  "Pass a local CampaignSpec (--spec <path-to-campaignspec.json>) " +
1380
1565
  "or fetch one from Map Builder (--map-id <id> --target <page-kit-dir>).",
@@ -1533,7 +1718,7 @@ function campaignIdentity(spec, args) {
1533
1718
  || optionalString(spec.spec_identity?.public_route_slug)
1534
1719
  || optionalString(spec.campaign?.slug)
1535
1720
  || optionalString(spec.campaign?.id);
1536
- return { mapId, publicRouteSlug };
1721
+ return { mapId, publicRouteSlug, localSpecId: spec.spec_identity?.local_spec_id ?? null };
1537
1722
  }
1538
1723
 
1539
1724
  function preferredTemplateFamily(spec) {
@@ -2146,11 +2331,8 @@ function parseWrapperPolicyFlag(args) {
2146
2331
  // directory are errors: the operator named the file, so silently falling back
2147
2332
  // to filesystem matching would discard the declaration they made.
2148
2333
  function parseDesignManifestFlag(args) {
2149
- const raw = args["design-manifest"];
2334
+ const raw = requireDesignManifestValue(args);
2150
2335
  if (raw == null) return null;
2151
- if (raw === true || !isNonEmptyString(raw)) {
2152
- throw new Error("--design-manifest needs a value: the path of a source-html-manifest/v0 JSON file.");
2153
- }
2154
2336
  const path = resolve(raw);
2155
2337
  if (!existsSync(path) || !statSync(path).isFile()) {
2156
2338
  throw new Error(`Design manifest does not exist or is not a file: ${path}`);
@@ -2158,6 +2340,14 @@ function parseDesignManifestFlag(args) {
2158
2340
  return path;
2159
2341
  }
2160
2342
 
2343
+ function requireDesignManifestValue(args) {
2344
+ const raw = args["design-manifest"];
2345
+ if (raw != null && (raw === true || !isNonEmptyString(raw))) {
2346
+ throw new Error("--design-manifest needs a value: the path of a source-html-manifest/v0 JSON file.");
2347
+ }
2348
+ return raw;
2349
+ }
2350
+
2161
2351
  // Same precedence the template family uses (docs/build-packet.md
2162
2352
  // "Authoring-Time Hints"): an explicit CLI flag beats a declared file hint,
2163
2353
  // and with neither the default stands. The vocabulary is the adapter
@@ -2208,21 +2398,18 @@ function prepareBuild(args, options = {}) {
2208
2398
  assertDistinctPrepareBuildOutputPaths(prepareBuildCollisionPaths);
2209
2399
  guardAssemblyReportOverwrite(reportPath, args);
2210
2400
  const spec = readJson(specPath);
2211
- const { mapId, publicRouteSlug } = campaignIdentity(spec, args);
2212
- if (!mapId) throw new Error("CampaignSpec has no map ID. Re-export a saved Map Builder spec with spec_identity.map_id before assembly; use --map-id only for legacy diagnostics.");
2213
- if (!publicRouteSlug) throw new Error("CampaignSpec has no public route slug. Re-export a saved Map Builder spec with spec_identity.public_route_slug or set campaign.slug.");
2214
-
2215
- const sourceKind = optionalString(args["source-kind"], "html_funnel");
2216
- if (sourceKind !== "html_funnel") {
2217
- throw new Error(`Unsupported source adapter "${sourceKind}". Use html_funnel for the current prepared-HTML flow.`);
2218
- }
2219
- // Validated here, with the other argv checks, rather than where the policy
2220
- // is consumed: prepare-build publishes an immutable Design Source Package
2221
- // partway through, so a flag that throws later would leave persistent state
2222
- // behind for a bad argument.
2223
- const wrapperPolicyFlag = parseWrapperPolicyFlag(args);
2401
+ const { mapId, publicRouteSlug, localSpecId } = campaignIdentity(spec, args);
2402
+ if (!resolveCampaignIdentity({ map_id: mapId, local_spec_id: localSpecId })) {
2403
+ throw new Error("CampaignSpec requires exactly one identity: a saved spec_identity.map_id, or an agent-authored spec_identity.local_spec_id (1–64 letters, digits, underscores or hyphens). Keep the local ID stable across revisions; do not invent a Map ID.");
2404
+ }
2405
+ if (!publicRouteSlug) throw new Error("CampaignSpec has no public route slug. Set spec_identity.public_route_slug or campaign.slug.");
2406
+
2407
+ // Dispatch validated the argv-only flags before spec resolution. Only the
2408
+ // manifest's filesystem check remains here, before preparation writes.
2409
+ const sourceKind = options.sourceKind;
2410
+ const wrapperPolicyFlag = options.wrapperPolicyFlag;
2224
2411
  const designManifestPath = parseDesignManifestFlag(args);
2225
- const orderPathDepthFlag = parseOrderPathDepthFlag(args, { command: "prepare-build" });
2412
+ const orderPathDepthFlag = options.orderPathDepthFlag;
2226
2413
 
2227
2414
  const activePages = activeSpecPages(spec);
2228
2415
  const htmlFiles = collectHtmlFiles(sourceRoot);
@@ -2444,6 +2631,9 @@ function prepareBuild(args, options = {}) {
2444
2631
  const packet = {
2445
2632
  schema_version: PACKET_SCHEMA,
2446
2633
  generated_at: new Date().toISOString(),
2634
+ // The kernel version that prepared this packet: the second pin source
2635
+ // `tooling status` reads when the project declares no exact devDependency.
2636
+ campaigns_os_version: packageVersion(),
2447
2637
  campaign: {
2448
2638
  public_route_slug: publicRouteSlug,
2449
2639
  ...(specRouteRoot ? { route_root: specRouteRoot } : {}),
@@ -2455,7 +2645,8 @@ function prepareBuild(args, options = {}) {
2455
2645
  },
2456
2646
  spec: {
2457
2647
  map_id: mapId,
2458
- spec_url: spec.spec_identity?.spec_url || null,
2648
+ ...(localSpecId ? { local_spec_id: localSpecId } : {}),
2649
+ spec_url: localSpecId ? null : spec.spec_identity?.spec_url || null,
2459
2650
  local_path: relFromFile(packetPath, specPath),
2460
2651
  },
2461
2652
  design_source_package: designSourcePackage.referenceFor(packetPath),
@@ -2820,6 +3011,7 @@ function createAssemblyReport({
2820
3011
  status: "prepared",
2821
3012
  identity: {
2822
3013
  map_id: packet.spec.map_id,
3014
+ ...localSpecIdentityFields(packet.spec),
2823
3015
  public_route_slug: packet.campaign.public_route_slug,
2824
3016
  campaign_directory: packet.campaign.campaign_directory,
2825
3017
  live_url_path: packet.campaign.live_url_path,
@@ -3218,7 +3410,7 @@ function rejectUnknownStandardizeFlags(args) {
3218
3410
  const valueHint = unknown.some((key) => key.includes("="))
3219
3411
  ? " A flag takes its value as the next argument (--flag value), not --flag=value."
3220
3412
  : "";
3221
- throw new Error(
3413
+ throw refused(
3222
3414
  `Unknown flag${unknown.length > 1 ? "s" : ""} for standardize: ${unknown.map((key) => `--${key}`).join(", ")}.${valueHint} Known flags: ${STANDARDIZE_FLAGS.map((key) => `--${key}`).join(", ")}.`,
3223
3415
  );
3224
3416
  }
@@ -3227,7 +3419,7 @@ function standardizationReportCommand(args) {
3227
3419
  rejectUnknownStandardizeFlags(args);
3228
3420
  const target = optionalString(args.target);
3229
3421
  if (!target) {
3230
- throw new Error("standardize requires --target <campaign-repo> (a Page Kit root, a parent repo, or a Campaign Cart application checkout).");
3422
+ throw refused("standardize requires --target <campaign-repo> (a Page Kit root, a parent repo, or a Campaign Cart application checkout).");
3231
3423
  }
3232
3424
  const family = optionalString(args.family) || optionalString(args["template-family"]);
3233
3425
  const slug = optionalString(args.slug);
@@ -3283,7 +3475,7 @@ function standardizationReportCommand(args) {
3283
3475
  function themeCommand(args) {
3284
3476
  const subcommand = args._[1] || "inspect";
3285
3477
  if (!["inspect", "generate", "waive"].includes(subcommand)) {
3286
- throw new Error(`Unknown theme subcommand "${subcommand}". Use: inspect | generate | waive.`);
3478
+ throw refused(`Unknown theme subcommand "${subcommand}". Use: inspect | generate | waive.`);
3287
3479
  }
3288
3480
  if (subcommand === "waive") return themeWaive(args);
3289
3481
  const packetPath = resolve(requireArg(args, "packet"));
@@ -3325,20 +3517,25 @@ function themeCommand(args) {
3325
3517
  // improvising past advisory prose.
3326
3518
  export function themeWaive(args) {
3327
3519
  const packetPath = resolve(requireArg(args, "packet"));
3520
+ const dryRun = isDryRun(args);
3328
3521
  const packet = readJson(packetPath);
3329
3522
  const reason = optionalString(args.reason);
3330
- if (!reason) throw new Error("theme waive requires --reason \"<why the starter palette is acceptable for this campaign>\".");
3523
+ // A missing flag, raised after reading nothing but argv and the packet: a
3524
+ // refusal, so the journal records nothing for it (docs/effects.md `*refused*`).
3525
+ if (!reason) throw refused("theme waive requires --reason \"<why the starter palette is acceptable for this campaign>\".");
3331
3526
  // The same attribution rule as `checkpoint waive`: a named human, no
3332
3527
  // placeholder, an expiry (when given) that lies in the future and is
3333
3528
  // recorded. A bound is not demanded here: the theme gate's waiver has always
3334
3529
  // been open-ended, and QA re-surfaces the starter palette on every run.
3335
- const waiver = validateWaiverAttribution({
3530
+ // A shared validator of argv alone, still ahead of the report read: its
3531
+ // throws are refusals at this call site (the position decides, not the file).
3532
+ const waiver = refusing(() => validateWaiverAttribution({
3336
3533
  reason,
3337
3534
  waivedBy: args["waived-by"] == null ? null : String(args["waived-by"]),
3338
3535
  expiresAt: args["expires-at"] == null ? null : String(args["expires-at"]),
3339
3536
  requireBound: false,
3340
3537
  label: "theme waive",
3341
- });
3538
+ }));
3342
3539
  const workspace = resolveCampaignWorkspace(packetPath, {
3343
3540
  packet,
3344
3541
  reportPath: args.report ? resolve(args.report) : undefined,
@@ -3346,7 +3543,7 @@ export function themeWaive(args) {
3346
3543
  });
3347
3544
  const { reportPath } = workspace;
3348
3545
  if (!existsSync(reportPath)) throw new Error(`theme waive needs an assembly report at ${reportPath}; run prepare-build/start first.`);
3349
- commitAssemblyReport(workspace, (report) => {
3546
+ const recordWaiver = (report) => {
3350
3547
  report.theme = report.theme && isObject(report.theme)
3351
3548
  ? { ...report.theme, waiver }
3352
3549
  : { status: "skipped", css_path: null, load_order: "not-applied", commerce_pages: [], evidence: [], warnings: [], repair_loop_defect: null, waiver };
@@ -3355,12 +3552,37 @@ export function themeWaive(args) {
3355
3552
  `Theme gate waived by ${waiver.waived_by} at ${waiver.waived_at}: ${reason}`,
3356
3553
  ];
3357
3554
  return report;
3358
- }, {
3555
+ };
3556
+ // Every check above is the real command's; only the commit is skipped. The
3557
+ // readiness block is not reported for a dry run because doctor reads it off
3558
+ // the report on disk, which by construction still has no waiver on it.
3559
+ //
3560
+ // Both modes go through the committing path itself (see
3561
+ // commitWaiverToAssemblyReport): the dry run used to return on the existsSync
3562
+ // check alone, so a torn report was reported as a successful `would_write`
3563
+ // with exit 0 while the real invocation refused with "Assembly Report ... is
3564
+ // not valid JSON" and exit 1, and a report that is not an object was previewed
3565
+ // as writable while the commit refused it. Validation must never be weaker
3566
+ // under --dry-run than without it.
3567
+ commitWaiverToAssemblyReport(workspace, recordWaiver, {
3359
3568
  // #171: the waiver changes what doctor would conclude; the retained doctor
3360
3569
  // sidecar (if any) now predates it.
3361
3570
  command: "theme waive",
3362
3571
  staleReason: `A theme-gate waiver was recorded after this doctor snapshot. Re-run ${cmd("doctor")} (or next) for current state.`,
3363
- });
3572
+ }, { dryRun });
3573
+ if (dryRun) {
3574
+ return {
3575
+ ok: true,
3576
+ status: "dry_run",
3577
+ dry_run: true,
3578
+ action: "theme-waive",
3579
+ gate: "theme_gate",
3580
+ waiver,
3581
+ report_path: reportPath,
3582
+ would_write: reportPath,
3583
+ note: "Dry run: nothing was written and the doctor sidecar was not marked stale. Re-run without --dry-run to record this waiver.",
3584
+ };
3585
+ }
3364
3586
  return {
3365
3587
  ok: true,
3366
3588
  ...waiveReadiness(packetPath, reportPath),
@@ -3378,6 +3600,44 @@ export function themeWaive(args) {
3378
3600
  // "unknown" fallback. Doctor is re-run rather than patched from the pre-waive
3379
3601
  // result because a waiver changes what every other gate concludes about the
3380
3602
  // stage. Nothing is persisted here; the sidecar was already marked stale.
3603
+ /**
3604
+ * The one route both waive commands take to the Assembly Report, real or
3605
+ * previewed. `commitAssemblyReport` is called identically in both modes — same
3606
+ * workspace, same mutator, same options — so every check the committing path
3607
+ * makes runs on both: the report must exist, it must parse (a torn one fails by
3608
+ * name), the mutator's own refusals fire, its result must be an Assembly Report
3609
+ * object, and the derived summary is restated over that result. A dry run
3610
+ * differs in one statement: this wrapper — not the mutator — returns `null` to
3611
+ * commitAssemblyReport, which is its "nothing to write" answer, so the report
3612
+ * is not rewritten and the doctor sidecar is not stamped stale. Nothing is
3613
+ * re-implemented and nothing is skipped but the write itself. A mutator that
3614
+ * returns nothing does not get to borrow that sentinel: it is a bug, and it
3615
+ * throws here on both paths.
3616
+ *
3617
+ * The result-shape check and the summary restatement sit here rather than being
3618
+ * left to commitAssemblyReport alone because they must run in BOTH modes and
3619
+ * commitAssemblyReport reaches its own copies only on the way to the write.
3620
+ * Running them here means one message and one order, not two: the real path
3621
+ * now fails on this line and never on the copy in stage-ledger.mjs, so the two
3622
+ * cannot drift into different text.
3623
+ */
3624
+ export function commitWaiverToAssemblyReport(workspace, mutate, options, { dryRun = false } = {}) {
3625
+ const previewOrCommit = (report) => {
3626
+ const mutated = mutate(report);
3627
+ // null and undefined are refused here rather than forwarded. Both waive
3628
+ // mutators always return the report they built, and commitAssemblyReport
3629
+ // reads a null result as "nothing to write" — so a mutator that forgot to
3630
+ // return would skip the write silently while the command still reported the
3631
+ // waiver recorded. That is a bug in the mutator and fails loudly. The dry
3632
+ // run's own "do not write" is this wrapper's null below, not the mutator's.
3633
+ if (!isPlainObject(mutated)) throw new TypeError("commitAssemblyReport mutate(report) must return an Assembly Report object.");
3634
+ if (!dryRun) return mutated;
3635
+ applyDerivedAssemblyReportSummary(mutated);
3636
+ return null;
3637
+ };
3638
+ return commitAssemblyReport(workspace, previewOrCommit, options);
3639
+ }
3640
+
3381
3641
  function waiveReadiness(packetPath, reportPath) {
3382
3642
  const doctor = doctorPacket(packetPath, { reportPath });
3383
3643
  return { status: doctor.status, next_stage: doctor.next?.stage || null, next_stage_reason: doctor.next?.reason || null };
@@ -3400,14 +3660,14 @@ function requireValidPolishCaptureReport(report, reportPath) {
3400
3660
  export async function polishCaptureCommand(args, options = {}) {
3401
3661
  const subcommand = args?._?.[1] || "help";
3402
3662
  if (subcommand !== "capture") {
3403
- throw new Error(
3663
+ throw refused(
3404
3664
  `Unknown polish subcommand. Use: ${cmd("polish")} capture --packet <campaign-runtime.build.json> --base-url <url> [--report <json>] [--headed] [--auth-cookie <cookie>] [--json].`,
3405
3665
  );
3406
3666
  }
3407
3667
  const packetPath = resolve(requireArg(args, "packet"));
3408
3668
  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");
3669
+ if (args.report === true) throw refused("Missing value for --report");
3670
+ if (args["auth-cookie"] === true) throw refused("Missing value for --auth-cookie");
3411
3671
 
3412
3672
  const packet = readJson(packetPath);
3413
3673
  const workspace = resolveCampaignWorkspace(packetPath, {
@@ -3547,13 +3807,14 @@ function waiveOrRefuse(args, run, { gate = null, registeredGates = [] } = {}) {
3547
3807
  function checkpointCommand(args) {
3548
3808
  const subcommand = args._[1] || "help";
3549
3809
  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(", ")}.`);
3810
+ 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
3811
  }
3552
3812
  return checkpointWaive(args);
3553
3813
  }
3554
3814
 
3555
3815
  export function checkpointWaive(args) {
3556
3816
  const packetPath = resolve(requireArg(args, "packet"));
3817
+ const dryRun = isDryRun(args);
3557
3818
  const gateId = requireArg(args, "gate").trim();
3558
3819
  const reason = requireArg(args, "reason");
3559
3820
  const waivedBy = requireArg(args, "waived-by");
@@ -3570,7 +3831,7 @@ export function checkpointWaive(args) {
3570
3831
 
3571
3832
  const doctor = doctorPacket(packetPath, { reportPath });
3572
3833
  let waiver = null;
3573
- commitAssemblyReport(workspace, (report) => {
3834
+ const recordWaiver = (report) => {
3574
3835
  const gate = evaluateCheckpointRegistry(CHECKPOINT_EVALUATORS, gateId, { doctor, packet, report });
3575
3836
  if (!gate) throw new Error(`Checkpoint gate "${gateId}" has no current evidence; repair the packet/spec/target and re-run doctor.`);
3576
3837
  if (gate.status !== "blocked") {
@@ -3591,10 +3852,30 @@ export function checkpointWaive(args) {
3591
3852
  `Checkpoint waiver: ${gateId} waived by ${waiver.waived_by} at ${waiver.waived_at}: ${waiver.reason}`,
3592
3853
  ];
3593
3854
  return updated;
3594
- }, {
3855
+ };
3856
+ // The gate registry decides waivability from the report, so both modes go
3857
+ // through the committing path itself (see commitWaiverToAssemblyReport): the
3858
+ // report is read and parsed the same way and the very same mutator runs over
3859
+ // it, so every refusal above fires exactly as it would for real. Only the
3860
+ // write and the doctor-sidecar stale stamp are skipped, and with them the
3861
+ // readiness block, which doctor can only read off a report on disk.
3862
+ commitWaiverToAssemblyReport(workspace, recordWaiver, {
3595
3863
  command: "checkpoint waive",
3596
3864
  staleReason: `A checkpoint waiver was recorded after this doctor snapshot. Re-run ${cmd("doctor")} (or next) for current state.`,
3597
- });
3865
+ }, { dryRun });
3866
+ if (dryRun) {
3867
+ return {
3868
+ ok: true,
3869
+ status: "dry_run",
3870
+ dry_run: true,
3871
+ action: "checkpoint-waive",
3872
+ gate: gateId,
3873
+ waiver,
3874
+ report_path: reportPath,
3875
+ would_write: reportPath,
3876
+ note: "Dry run: nothing was written and the doctor sidecar was not marked stale. Re-run without --dry-run to record this waiver.",
3877
+ };
3878
+ }
3598
3879
  return {
3599
3880
  ok: true,
3600
3881
  ...waiveReadiness(packetPath, reportPath),
@@ -3626,11 +3907,17 @@ export function doctorPacket(packetPath, options = {}) {
3626
3907
  // to go by first. Code granularity, because that is the granularity the
3627
3908
  // record stores. baseDir is the packet directory, the same root the Run
3628
3909
  // Record writes under.
3910
+ // An invalid identity cannot select history. In particular, withholding a
3911
+ // malformed local ID from derived must not turn it into an unfiltered or
3912
+ // Map-only lookup of another campaign's findings.
3913
+ const comparableIdentity = resolveCampaignIdentity(result.derived)
3914
+ && !result.errors.some(issue => issue.code === "spec.local_identity" || issue.code === "spec.map_id");
3629
3915
  result.cause_summary = annotateDoctorIssueCauses({
3630
3916
  errors: result.errors,
3631
3917
  warnings: result.warnings,
3632
- baseDir: dirname(resolve(packetPath)),
3918
+ baseDir: comparableIdentity ? dirname(resolve(packetPath)) : null,
3633
3919
  mapId: result.derived?.map_id || null,
3920
+ localSpecId: result.derived?.local_spec_id || null,
3634
3921
  });
3635
3922
  return result;
3636
3923
  }
@@ -3650,6 +3937,7 @@ function inspectDoctorPacket(packetPath, { contextPath = undefined, reportPath =
3650
3937
  const errors = [];
3651
3938
  const warnings = [];
3652
3939
  const ready = [];
3940
+ const packetIdentity = resolveCampaignIdentity(packet?.spec);
3653
3941
  const derived = {
3654
3942
  packet_path: packetPath,
3655
3943
  // The report this inspection read (null when the caller switched the
@@ -3657,6 +3945,7 @@ function inspectDoctorPacket(packetPath, { contextPath = undefined, reportPath =
3657
3945
  // different file.
3658
3946
  assembly_report_path: typeof resolvedReportPath === "string" ? resolvedReportPath : null,
3659
3947
  map_id: packet?.spec?.map_id || null,
3948
+ ...(packetIdentity?.kind === "local_spec" ? { local_spec_id: packetIdentity.id } : {}),
3660
3949
  public_route_slug: packet?.campaign?.public_route_slug || null,
3661
3950
  template_family: packet?.assembly?.template_family || null,
3662
3951
  source_root: null,
@@ -4090,7 +4379,10 @@ function validatePacket(packet, packetPath, errors, warnings, ready, derived, bu
4090
4379
 
4091
4380
  requireString(packet, errors, "campaign.public_route_slug");
4092
4381
  requireBoolean(packet, errors, "campaign.allowed_domains_confirmed");
4093
- requireString(packet, errors, "spec.map_id");
4382
+ if (!resolveCampaignIdentity(packet.spec)) addIssue(errors, packet.spec?.local_spec_id != null ? "spec.local_identity" : "spec.map_id", "Packet spec requires exactly one valid map_id or local_spec_id.", { kind: packet.spec?.local_spec_id != null ? "local_spec" : "saved_map" });
4383
+ if (packet.spec?.local_spec_id != null && (packet.spec.spec_url != null || !isNonEmptyString(packet.spec.local_path))) {
4384
+ addIssue(errors, "spec.local_identity", "Local-spec packets require a local_path and no saved-Map spec_url.");
4385
+ }
4094
4386
  if (!synthesizedBuiltSite) {
4095
4387
  requireString(packet, errors, "source_html.root");
4096
4388
  requireArray(packet, errors, "source_html.pages");
@@ -4276,8 +4568,11 @@ function validatePacket(packet, packetPath, errors, warnings, ready, derived, bu
4276
4568
  buildState.specStatus = specStatus;
4277
4569
  if (specStatus === "ok") {
4278
4570
  const specMapId = spec.spec_identity?.map_id || spec.map_id;
4279
- if (specMapId && specMapId !== packet.spec.map_id) {
4280
- addIssue(errors, "spec.map_id", `Packet map_id "${packet.spec.map_id}" does not match CampaignSpec map_id "${specMapId}".`);
4571
+ if ((specMapId && specMapId !== packet.spec.map_id)
4572
+ || ((spec.spec_identity?.local_spec_id != null || packet.spec?.local_spec_id != null)
4573
+ && !campaignIdentitiesMatch(campaignSpecIdentity(spec), packet.spec))) {
4574
+ const localIdentity = spec.spec_identity?.local_spec_id != null || packet.spec?.local_spec_id != null;
4575
+ addIssue(errors, localIdentity ? "spec.local_identity" : "spec.map_id", "Packet identity does not match the CampaignSpec map_id/local_spec_id.", { kind: localIdentity ? "local_spec" : "saved_map" });
4281
4576
  }
4282
4577
  ready.push("Local CampaignSpec parsed");
4283
4578
  runDoctorChecks(SPEC_DOCTOR_CHECKS, { packet, packetPath, spec, targetRepo, errors, warnings, ready, derived, buildState });
@@ -4685,16 +4980,10 @@ export function pageKitSyncCommand(args) {
4685
4980
  const unknown = Object.keys(args).filter((key) => key !== "_" && !PAGE_KIT_SYNC_FLAGS.includes(key));
4686
4981
  if (unknown.length) {
4687
4982
  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(", ")}.`);
4983
+ 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
4984
  }
4690
4985
  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;
4986
+ const dryRun = isDryRun(args);
4698
4987
  const result = {
4699
4988
  ok: false,
4700
4989
  action: "page-kit sync",
@@ -4778,6 +5067,9 @@ export function pageKitSyncCommand(args) {
4778
5067
  addIssue(result.errors, "page_kit.sync.spec_identity_mismatch", `CampaignSpec identifies route "${singleLineField(specSlug)}" but the packet's campaign.public_route_slug is "${publicRouteSlug}". Point spec.local_path at this campaign's export (or re-run prepare-build from it); nothing was written.`);
4779
5068
  } else if (specMapId && packetMapId && specMapId !== packetMapId) {
4780
5069
  addIssue(result.errors, "page_kit.sync.spec_identity_mismatch", `CampaignSpec spec_identity.map_id "${singleLineField(specMapId)}" does not match the packet's spec.map_id "${singleLineField(packetMapId)}". Point spec.local_path at this campaign's export (or re-run prepare-build from it); nothing was written.`);
5070
+ } else if ((spec.spec_identity?.local_spec_id != null || packet.spec?.local_spec_id != null)
5071
+ && !campaignIdentitiesMatch(campaignSpecIdentity(spec), packet.spec)) {
5072
+ addIssue(result.errors, "page_kit.sync.spec_identity_mismatch", "CampaignSpec identity (spec_identity.map_id/local_spec_id) does not match the packet identity. Point spec.local_path at this campaign's spec (or re-run prepare-build from it); nothing was written.");
4781
5073
  }
4782
5074
  }
4783
5075
 
@@ -4918,8 +5210,8 @@ export function pageKitSyncCommand(args) {
4918
5210
  // refereed by doctor. Exactly the derived fields are written; the rest of the
4919
5211
  // spec and every other file are untouched. The store-derived fields (the nine
4920
5212
  // 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);
5213
+ // --from-store <subdomain>, which reads the store's Admin API
5214
+ // through gateway login, or the explicit --store-token-source env:<VAR> break-glass path;
4923
5215
  // the default run stays offline. --dry-run prints the same diff and writes
4924
5216
  // nothing. Exit 2 when the packet, the spec or the target entry is missing,
4925
5217
  // or the store cannot be read.
@@ -4931,17 +5223,17 @@ const SPEC_DERIVE_FLAGS = Object.freeze(["packet", "dry-run", "json", "report",
4931
5223
  // mistake as an unknown flag: refused before anything is read).
4932
5224
  function parseSpecDeriveStoreFlags(args) {
4933
5225
  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>.");
5226
+ if (Object.hasOwn(args, "store-token-source")) throw refused("--store-token-source only applies with --from-store <subdomain>.");
4935
5227
  return null;
4936
5228
  }
4937
5229
  const subdomain = normalizeStoreSubdomain(args["from-store"] === true ? "" : String(args["from-store"] ?? ""));
4938
5230
  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"])}.`);
5231
+ 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
5232
  }
4941
- let tokenEnv = defaultStoreTokenEnvVar(subdomain);
5233
+ let tokenEnv = null;
4942
5234
  if (Object.hasOwn(args, "store-token-source")) {
4943
5235
  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}`);
5236
+ if (parsed.problem) throw refused(`--store-token-source ${parsed.problem}`);
4945
5237
  tokenEnv = parsed.env;
4946
5238
  }
4947
5239
  return { subdomain, token_env: tokenEnv };
@@ -4950,23 +5242,22 @@ function parseSpecDeriveStoreFlags(args) {
4950
5242
  // `spec derive --from-store`: resolve the credential, read the store, then
4951
5243
  // run the same command with the store read in hand. The only network the
4952
5244
  // 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 } = {}) {
5245
+ // the result names the credential source, never its value.
5246
+ export async function specDeriveFromStoreCommand(args, { fetchImpl = globalThis.fetch, env = process.env, credentials, warn = console.warn } = {}) {
4955
5247
  const store = parseSpecDeriveStoreFlags(args);
4956
5248
  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
5249
  const preflight = specDeriveCommand(args, { store: { ...store, status: "preflight" } });
4965
5250
  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.
5251
+ let read;
5252
+ if (store.token_env) {
5253
+ 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.");
5254
+ const token = typeof env[store.token_env] === "string" ? env[store.token_env].trim() : "";
5255
+ 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.` };
5256
+ } else {
5257
+ const { readGatewayStoreProfile } = await import("./admin-transport.mjs");
5258
+ read = await readGatewayStoreProfile({ subdomain: store.subdomain, credentials, fetchImpl });
5259
+ }
5260
+ // Recheck the original packet/spec identity after the network operation.
4970
5261
  return specDeriveCommand(args, { store: { ...store, ...read, expected: { spec_path: preflight.spec_path, public_route_slug: preflight.public_route_slug } } });
4971
5262
  }
4972
5263
 
@@ -5019,7 +5310,7 @@ export function specDeriveCommand(args, { store: storeRead = null } = {}) {
5019
5310
  const unknown = Object.keys(args).filter((key) => key !== "_" && !SPEC_DERIVE_FLAGS.includes(key));
5020
5311
  if (unknown.length) {
5021
5312
  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(", ")}.`);
5313
+ 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
5314
  }
5024
5315
  // The store read is supplied by specDeriveFromStoreCommand; this function
5025
5316
  // never touches the network itself, so a --from-store call that reaches it
@@ -5027,13 +5318,8 @@ export function specDeriveCommand(args, { store: storeRead = null } = {}) {
5027
5318
  const storeFlags = parseSpecDeriveStoreFlags(args);
5028
5319
  if (storeFlags && !storeRead) throw new Error("spec derive --from-store must be dispatched through specDeriveFromStoreCommand (no store read was supplied).");
5029
5320
  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;
5321
+ const dryRun = isDryRun(args);
5322
+ if (args.report === true) throw refused("Missing value for --report");
5037
5323
  const result = {
5038
5324
  ok: false,
5039
5325
  action: "spec derive",
@@ -5053,7 +5339,7 @@ export function specDeriveCommand(args, { store: storeRead = null } = {}) {
5053
5339
  not_in_target: [],
5054
5340
  stale_hints: [],
5055
5341
  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 }
5342
+ ? { 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
5343
  : null,
5058
5344
  rebound: { build_context: null, assembly_report: null },
5059
5345
  errors: [],
@@ -5127,6 +5413,9 @@ export function specDeriveCommand(args, { store: storeRead = null } = {}) {
5127
5413
  addIssue(result.errors, "spec.derive.spec_identity_mismatch", `CampaignSpec identifies route "${singleLineField(specSlug)}" but the packet's campaign.public_route_slug is "${publicRouteSlug}". Point spec.local_path at this campaign's export (or re-run prepare-build from it); nothing was written.`);
5128
5414
  } else if (specMapId && packetMapId && specMapId !== packetMapId) {
5129
5415
  addIssue(result.errors, "spec.derive.spec_identity_mismatch", `CampaignSpec spec_identity.map_id "${singleLineField(specMapId)}" does not match the packet's spec.map_id "${singleLineField(packetMapId)}". Point spec.local_path at this campaign's export (or re-run prepare-build from it); nothing was written.`);
5416
+ } else if ((spec.spec_identity?.local_spec_id != null || packet.spec?.local_spec_id != null)
5417
+ && !campaignIdentitiesMatch(campaignSpecIdentity(spec), packet.spec)) {
5418
+ addIssue(result.errors, "spec.derive.spec_identity_mismatch", "CampaignSpec identity (spec_identity.map_id/local_spec_id) does not match the packet identity. Point spec.local_path at this campaign's spec (or re-run prepare-build from it); nothing was written.");
5130
5419
  }
5131
5420
  }
5132
5421
 
@@ -5236,8 +5525,8 @@ export function specDeriveCommand(args, { store: storeRead = null } = {}) {
5236
5525
  return result;
5237
5526
  }
5238
5527
  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}` });
5528
+ 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" };
5529
+ 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
5530
  return result;
5242
5531
  }
5243
5532
  const plan = planSpecDerive({ spec, entry, pageFiles, packetBindings, waivedGates, waiversUnknown, publicRouteSlug });
@@ -5440,7 +5729,7 @@ export function specDeriveTextLines(result) {
5440
5729
  if (result.spec_path) lines.push(`Spec: ${singleLineField(result.spec_path)}`);
5441
5730
  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
5731
  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}` : ""})`);
5732
+ 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
5733
  }
5445
5734
  if (result.errors?.length) {
5446
5735
  lines.push("Errors:");
@@ -5481,9 +5770,9 @@ const PAGE_KIT_PARITY_FLAGS = Object.freeze(["packet", "json", "report"]);
5481
5770
  export function pageKitParityCommand(args, options = {}) {
5482
5771
  const unknown = Object.keys(args).filter((key) => key !== "_" && !PAGE_KIT_PARITY_FLAGS.includes(key));
5483
5772
  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(", ")}.`);
5773
+ 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
5774
  }
5486
- if (args.report === true) throw new Error("Missing value for --report");
5775
+ if (args.report === true) throw refused("Missing value for --report");
5487
5776
  const packetPath = resolve(requireArg(args, "packet"));
5488
5777
  const result = {
5489
5778
  ok: false,
@@ -5911,15 +6200,15 @@ function validateSpecPackageAvailability(spec, warnings, ready) {
5911
6200
 
5912
6201
  function validateSpecIdentityExport(spec, warnings, ready) {
5913
6202
  const identity = spec?.spec_identity;
5914
- if (isObject(identity) && isNonEmptyString(identity.map_id) && isNonEmptyString(identity.public_route_slug)) {
5915
- ready.push("CampaignSpec spec_identity includes map_id and public_route_slug");
6203
+ if (isObject(identity) && resolveCampaignIdentity(identity) && isNonEmptyString(identity.public_route_slug)) {
6204
+ ready.push(`CampaignSpec spec_identity includes ${identity.local_spec_id ? "local_spec_id" : "map_id"} and public_route_slug`);
5916
6205
  return;
5917
6206
  }
5918
6207
 
5919
6208
  addIssue(
5920
6209
  warnings,
5921
6210
  "spec_identity.export",
5922
- "CampaignSpec is missing complete spec_identity.map_id/public_route_slug. Prefer re-exporting from a saved Map Builder map; CLI identity overrides should stay diagnostic-only."
6211
+ "CampaignSpec is missing complete spec_identity: declare map_id for a saved Map or local_spec_id for a local spec, plus public_route_slug. CLI identity overrides should stay diagnostic-only."
5923
6212
  );
5924
6213
  }
5925
6214
 
@@ -8802,7 +9091,12 @@ function validateAssemblyReport(report, { checkSourcePackageFreshness = true } =
8802
9091
  if (report.schema_version !== REPORT_SCHEMA) addIssue(errors, "schema_version", `Expected ${REPORT_SCHEMA}.`);
8803
9092
  else ready.push(`Assembly report schema ${REPORT_SCHEMA}`);
8804
9093
  for (const path of ["run_id", "generated_at", "status", "identity.map_id", "identity.public_route_slug", "inputs.packet_path", "template_family.value"]) {
8805
- requireString(report, errors, path);
9094
+ if (path === "identity.map_id") {
9095
+ if (!resolveCampaignIdentity(report.identity)) addIssue(errors, "identity.map_id", "Assembly Report requires exactly one valid map_id or local_spec_id.");
9096
+ } else requireString(report, errors, path);
9097
+ }
9098
+ if (report.identity?.local_spec_id != null && !/^sha256:[0-9a-f]{64}$/.test(report.identity.spec_material_hash || "")) {
9099
+ addIssue(errors, "identity.spec_material_hash", "Local-spec reports require a current SHA-256 material spec hash.");
8806
9100
  }
8807
9101
  const stages = report.stages;
8808
9102
  if (!isObject(stages)) {
@@ -9052,13 +9346,14 @@ function nextPrepareBuildBindingIssues({
9052
9346
  const expectedSlug = optionalString(packet.campaign?.public_route_slug);
9053
9347
  const recordedMapId = optionalString(report.identity?.map_id);
9054
9348
  const recordedSlug = optionalString(report.identity?.public_route_slug);
9055
- if (recordedMapId !== expectedMapId || recordedSlug !== expectedSlug) {
9349
+ if (!campaignIdentitiesMatch(packet.spec, report.identity) || recordedSlug !== expectedSlug) {
9056
9350
  push(
9057
9351
  "next.prepare_build.report_campaign_mismatch",
9058
9352
  "Assembly Report campaign identity does not match the current Build Packet.",
9059
9353
  {
9060
- expected: { map_id: expectedMapId, public_route_slug: expectedSlug },
9061
- recorded: { map_id: recordedMapId, public_route_slug: recordedSlug },
9354
+ // Failed identity fields are diagnostic data, never adopted evidence.
9355
+ expected: { map_id: expectedMapId, local_spec_id: packet.spec?.local_spec_id ?? null, public_route_slug: expectedSlug },
9356
+ recorded: { map_id: recordedMapId, local_spec_id: report.identity?.local_spec_id ?? null, public_route_slug: recordedSlug },
9062
9357
  },
9063
9358
  );
9064
9359
  }
@@ -9467,6 +9762,9 @@ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate
9467
9762
  }
9468
9763
 
9469
9764
  export function nextStage(stage, args, ambient = null) {
9765
+ if (stage !== null && !NEXT_STAGE_ORDER.includes(stage)) {
9766
+ throw refused(`Unknown next stage: ${stage}. Accepted stages: ${NEXT_STAGE_ORDER.join(", ")}.`);
9767
+ }
9470
9768
  const packetPath = resolve(requireArg(args, "packet"));
9471
9769
  // A custom prepare-build report is recorded on the Build Context relative
9472
9770
  // to the target repo. Packet-only `next` must follow that durable pointer;
@@ -9672,8 +9970,6 @@ export function nextStage(stage, args, ambient = null) {
9672
9970
  addPolishCheckpointGateErrors(errors, polishCheckpointGate, "qa");
9673
9971
  addThemeGateErrors(errors, themeGate, "qa");
9674
9972
  prompt = qaPrompt(packetPath, reportPath, packet);
9675
- } else {
9676
- throw new Error(`Unknown next stage: ${stage}`);
9677
9973
  }
9678
9974
  const status = errors.length
9679
9975
  ? "blocked"
@@ -10380,7 +10676,7 @@ function qaPrompt(packetPath, reportPath, packet) {
10380
10676
  const briefPath = packet.build_brief?.normalized_path || "(missing)";
10381
10677
  return `Use next-campaigns-qa for this deployed campaign.
10382
10678
 
10383
- Map ID: ${packet.spec.map_id}
10679
+ ${packet.spec.local_spec_id ? "Local spec ID" : "Map ID"}: ${packet.spec.local_spec_id || packet.spec.map_id}
10384
10680
  Base URL: ${url}
10385
10681
  Build Packet: ${packetPath}
10386
10682
  Assembly Report: ${reportPath}
@@ -10784,7 +11080,7 @@ function resolveSkillInstallTargets(targetArg = null, platformArg = null) {
10784
11080
  : SKILL_PLATFORMS.filter((platform) => platform.id === requested);
10785
11081
 
10786
11082
  if (!selected.length) {
10787
- throw new Error(`Unknown --platform ${requested}. Use one of: ${skillPlatformHelp()}.`);
11083
+ throw refused(`Unknown --platform ${requested}. Use one of: ${skillPlatformHelp()}.`);
10788
11084
  }
10789
11085
 
10790
11086
  return selected.map((platform) => ({
@@ -10847,6 +11143,31 @@ function installSkills(targetArg = null, dryRun = false, platformArg = null) {
10847
11143
  };
10848
11144
  }
10849
11145
 
11146
+ // A platform directory counts as installed when a skill already sits under one
11147
+ // of the bundled names (current or not), or our own copy under a retired name. A
11148
+ // slot install-skills would only create says nothing about that platform, and
11149
+ // neither does a retired slot another skill occupies. A foreign skill under a
11150
+ // CURRENT bundled name reads as `updated` — install-skills would replace it —
11151
+ // so it does count; the refresh action is then what install-skills would do.
11152
+ const SKILL_ACTIONS_THAT_MARK_A_PLATFORM_INSTALLED = new Set(["unchanged", "updated", "retired"]);
11153
+
11154
+ export function scopeSkillStatusToInstalledPlatforms(status) {
11155
+ if (!Array.isArray(status?.targets)) return { status, scope: "requested", notInstalled: [] };
11156
+ const installedOn = (target) => (target.skills || []).some((skill) => SKILL_ACTIONS_THAT_MARK_A_PLATFORM_INSTALLED.has(skill?.action));
11157
+ const installed = status.targets.filter(installedOn);
11158
+ const describe = (target) => ({
11159
+ platform: target.platform,
11160
+ platform_label: target.platform_label,
11161
+ target_directory: target.target_directory,
11162
+ });
11163
+ if (!installed.length) return { status, scope: "no_platform_installed", notInstalled: status.targets.map(describe) };
11164
+ return {
11165
+ status: { ...status, targets: installed, skills: installed.flatMap((target) => target.skills) },
11166
+ scope: "installed_platforms",
11167
+ notInstalled: status.targets.filter((target) => !installedOn(target)).map(describe),
11168
+ };
11169
+ }
11170
+
10850
11171
  const TOOLING_ACTIONABLE_SKILL_ACTIONS = new Set(["created", "updated", "retired"]);
10851
11172
  const TOOLING_CLEAN_SKILL_ACTIONS = new Set(["unchanged"]);
10852
11173
 
@@ -10871,14 +11192,344 @@ function toolingSkillIdentity(skill) {
10871
11192
  return `${prefix}${skill?.name || "unknown skill"}`;
10872
11193
  }
10873
11194
 
11195
+ /**
11196
+ * `--skills-revision <value>`: the skills bundle identity an agent read, checked
11197
+ * against the bundle THIS CLI ships.
11198
+ *
11199
+ * The asymmetry is the whole point, and it is why the reported revision is named
11200
+ * `on_disk`. A skill's text is pulled into an agent's context once, at the start
11201
+ * of the task, and is never re-read; the CLI on disk, meanwhile, can be updated
11202
+ * underneath that session by an `npm install`, an `npx` cache refresh, or a
11203
+ * `git pull` in the checkout. So the only honest comparison is "what you are
11204
+ * still reading" against "what is installed right now", and the only honest
11205
+ * remedy for a mismatch is a fresh session — re-running the command cannot pull
11206
+ * the newer skill text into a context that already has the older one.
11207
+ *
11208
+ * The bundle spelling (`<package version>+skills.<n>`) is what every SKILL.md
11209
+ * states on its first body line. The `<skill-id>@<version>` spelling is a
11210
+ * fallback for an agent that carries only the frontmatter of the one skill it
11211
+ * loaded; it is checked against that skill's manifest entry. An id this bundle
11212
+ * does not ship is a mismatch, not a refusal: an agent quoting a skill that is
11213
+ * not here is reading text from some other bundle, which is exactly the
11214
+ * condition this flag exists to catch.
11215
+ */
11216
+ export function parseSkillsRevisionArg(value) {
11217
+ const text = String(value).trim();
11218
+ const at = text.lastIndexOf("@");
11219
+ if (at > 0 && at < text.length - 1) {
11220
+ return { spelling: "skill", id: text.slice(0, at), version: text.slice(at + 1), requested: text };
11221
+ }
11222
+ return { spelling: "bundle", requested: text };
11223
+ }
11224
+
11225
+ export function evaluateSkillsRevision(value, manifest) {
11226
+ const onDisk = typeof manifest?.bundle_revision === "string" ? manifest.bundle_revision : null;
11227
+ if (value === undefined) {
11228
+ return {
11229
+ status: "unchecked",
11230
+ requested: null,
11231
+ spelling: null,
11232
+ on_disk: onDisk,
11233
+ on_disk_skill: null,
11234
+ message: `unchecked (on disk ${onDisk || "unknown"})`,
11235
+ };
11236
+ }
11237
+ const parsed = parseSkillsRevisionArg(value);
11238
+ if (parsed.spelling === "skill") {
11239
+ const entry = (manifest?.skills || []).find((skill) => skill?.id === parsed.id) || null;
11240
+ const onDiskSkill = entry ? { id: entry.id, version: entry.version ?? null } : null;
11241
+ const match = Boolean(entry) && entry.version === parsed.version;
11242
+ return {
11243
+ status: match ? "match" : "mismatch",
11244
+ requested: parsed.requested,
11245
+ spelling: "skill",
11246
+ on_disk: onDisk,
11247
+ on_disk_skill: onDiskSkill,
11248
+ message: match
11249
+ ? `match (${parsed.requested}; bundle ${onDisk || "unknown"})`
11250
+ : `mismatch: loaded ${parsed.requested}, on disk ${
11251
+ onDiskSkill ? `${onDiskSkill.id}@${onDiskSkill.version}` : `no skill named ${parsed.id}`
11252
+ } (bundle ${onDisk || "unknown"}) — start a fresh session`,
11253
+ };
11254
+ }
11255
+ const match = Boolean(onDisk) && onDisk === parsed.requested;
11256
+ return {
11257
+ status: match ? "match" : "mismatch",
11258
+ requested: parsed.requested,
11259
+ spelling: "bundle",
11260
+ on_disk: onDisk,
11261
+ on_disk_skill: null,
11262
+ message: match
11263
+ ? `match (${onDisk})`
11264
+ : `mismatch: loaded ${parsed.requested}, on disk ${onDisk || "unknown"} — start a fresh session`,
11265
+ };
11266
+ }
11267
+
11268
+ export function skillsRevisionTextLines(result) {
11269
+ const revision = result?.skills_revision;
11270
+ return revision ? [`Skills revision: ${revision.message}`] : [];
11271
+ }
11272
+
11273
+ // The pin checks (ADR 0002, campaigns-os#466): one executable per project. The
11274
+ // project pin (an exact devDependency or dependency on this package) comes
11275
+ // first, the kernel version the Build Packet records second. Only an exact
11276
+ // version is a pin — a range or tag names no one executable, so it is reported
11277
+ // as `range` and the project counts as unpinned.
11278
+ const PIN_PACKAGE_NAME = "@nextcommerce/campaigns-os";
11279
+ const EXACT_VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
11280
+ // npm reads `=1.2.3` and `v1.2.3` as the exact version 1.2.3.
11281
+ const EXACT_PIN_SPEC_RE = /^=?v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$/;
11282
+ // peerDependencies and optionalDependencies install nothing this project runs.
11283
+ const PIN_KEYS = ["devDependencies", "dependencies"];
11284
+ const PIN_BLOCKING_STATUSES = new Set(["conflicting_pin", "stale_pin"]);
11285
+
11286
+ function exactPinVersion(spec) {
11287
+ return typeof spec === "string" ? EXACT_PIN_SPEC_RE.exec(spec)?.[1] ?? null : null;
11288
+ }
11289
+
11290
+ // An installed package's own manifest (node_modules/<name>/package.json or
11291
+ // node_modules/@<scope>/<name>/package.json) is never the project: run from
11292
+ // inside an install, the walk resolves the enclosing project as if the working
11293
+ // directory were that project. Any other manifest is a candidate, even one with
11294
+ // a node_modules segment higher up its path.
11295
+ function isInstalledPackageDir(dir) {
11296
+ const parent = dirname(dir);
11297
+ if (basename(parent) === "node_modules") return true;
11298
+ return basename(parent).startsWith("@") && basename(dirname(parent)) === "node_modules";
11299
+ }
11300
+
11301
+ function nearestPackageJson(startDir) {
11302
+ for (let dir = resolve(startDir); ; dir = dirname(dir)) {
11303
+ const candidate = join(dir, "package.json");
11304
+ if (!isInstalledPackageDir(dir) && existsSync(candidate) && statSync(candidate).isFile()) return candidate;
11305
+ if (dirname(dir) === dir) return null;
11306
+ }
11307
+ }
11308
+
11309
+ // npm's array form, or the `{ packages: [...] }` object form yarn also reads.
11310
+ function declaresWorkspaces(manifest) {
11311
+ const workspaces = manifest.workspaces;
11312
+ return Array.isArray(workspaces) || (isObject(workspaces) && Array.isArray(workspaces.packages));
11313
+ }
11314
+
11315
+ // An empty or whitespace-only spec pins nothing, so it counts as absent rather
11316
+ // than as a range.
11317
+ function pinSpecsIn(manifest) {
11318
+ return PIN_KEYS.flatMap((key) => {
11319
+ const spec = manifest?.[key]?.[PIN_PACKAGE_NAME];
11320
+ return typeof spec === "string" && spec.trim() ? [{ key, spec: spec.trim() }] : [];
11321
+ });
11322
+ }
11323
+
11324
+ // The project pin is the first exact spec on the walk up from the nearest
11325
+ // manifest, devDependencies before dependencies in each; a range is kept only
11326
+ // if nothing exact turns up. A manifest that names nothing is
11327
+ // neutral and walked through: it cannot supply a pin, so stopping there would
11328
+ // only hide one higher up. The walk ends after a workspace root, at the
11329
+ // filesystem root, and at a manifest it cannot read, with a warning, since
11330
+ // that one might have held the pin.
11331
+ function resolveProjectPin(packageJsonPath, warnings) {
11332
+ let range = null;
11333
+ for (let path = packageJsonPath; path; path = nearestPackageJson(dirname(dirname(path)))) {
11334
+ const problem = {};
11335
+ const manifest = readPinJson(path, problem);
11336
+ if (!manifest) {
11337
+ warnings.push(path === packageJsonPath
11338
+ ? `Project pin unavailable: ${path} ${problem.reason}.`
11339
+ : `Project pin walk stopped at ${path}: it ${problem.reason}.`);
11340
+ break;
11341
+ }
11342
+ const specs = pinSpecsIn(manifest);
11343
+ const exact = specs.find(({ spec }) => exactPinVersion(spec));
11344
+ if (exact) return { projectSpec: exact.spec, projectManifest: path, projectKey: exact.key };
11345
+ if (!range && specs.length) range = { projectSpec: specs[0].spec, projectManifest: path, projectKey: specs[0].key };
11346
+ if (declaresWorkspaces(manifest) || dirname(dirname(path)) === dirname(path)) break;
11347
+ }
11348
+ return range || { projectSpec: null, projectManifest: packageJsonPath, projectKey: null };
11349
+ }
11350
+
11351
+ // A malformed file is a missing source plus a warning, never a crash: the pin
11352
+ // line is one report among several and must not take the status down with it.
11353
+ // A leading BOM is valid to npm, so it is valid here.
11354
+ function readPinJson(path, problem = {}) {
11355
+ let text;
11356
+ try {
11357
+ text = readFileSync(path, "utf8");
11358
+ } catch (error) {
11359
+ problem.reason = `could not be read (${error.code || error.message})`;
11360
+ return null;
11361
+ }
11362
+ let value;
11363
+ try {
11364
+ value = JSON.parse(text.replace(/^\uFEFF/, ""));
11365
+ } catch {
11366
+ problem.reason = "is not valid JSON";
11367
+ return null;
11368
+ }
11369
+ if (isObject(value)) return value;
11370
+ problem.reason = "is not a JSON object";
11371
+ return null;
11372
+ }
11373
+
11374
+ /** The project and packet sources `tooling status` compares, read from disk. */
11375
+ export function resolvePinSources(args, { cwd = process.cwd() } = {}) {
11376
+ const warnings = [];
11377
+ const explicitPacket = optionalString(args.packet);
11378
+ let packetPath = explicitPacket ? resolve(cwd, explicitPacket) : null;
11379
+ let packet = null;
11380
+ const packetProblem = {};
11381
+ if (packetPath) {
11382
+ if (!existsSync(packetPath)) throw refused(`Build Packet not found: ${packetPath}`);
11383
+ packet = readPinJson(packetPath, packetProblem);
11384
+ }
11385
+ // An explicit packet names its project: its target repo, whatever the cwd.
11386
+ const projectStart = packet ? targetRepoFor(packetPath, packet) : cwd;
11387
+ const packageJsonPath = nearestPackageJson(projectStart);
11388
+ if (!packetPath) {
11389
+ // The contracted home of the packet is the project root beside package.json
11390
+ // (prepare-build's default --out), the same place readback discovers it.
11391
+ packetPath = join(packageJsonPath ? dirname(packageJsonPath) : resolve(cwd), "campaign-runtime.build.json");
11392
+ if (existsSync(packetPath)) packet = readPinJson(packetPath, packetProblem);
11393
+ }
11394
+
11395
+ const { projectSpec, projectManifest, projectKey } = resolveProjectPin(packageJsonPath, warnings);
11396
+
11397
+ let packetVersion = null;
11398
+ let packetVersionIgnored = null;
11399
+ if (packet && Object.hasOwn(packet, "campaigns_os_version")) {
11400
+ const recorded = packet.campaigns_os_version;
11401
+ if (typeof recorded === "string" && EXACT_VERSION_RE.test(recorded)) {
11402
+ packetVersion = recorded;
11403
+ } else {
11404
+ // Kept verbatim (a non-string as its JSON text) so the Pin: line can say
11405
+ // the field was there and ignored, not that it was absent.
11406
+ packetVersionIgnored = typeof recorded === "string" ? recorded : JSON.stringify(recorded);
11407
+ warnings.push(`Packet campaigns_os_version ignored: ${JSON.stringify(recorded)} in ${packetPath} is not an exact version.`);
11408
+ }
11409
+ } else if (existsSync(packetPath) && !packet) {
11410
+ warnings.push(`Packet pin unavailable: ${packetPath} ${packetProblem.reason}.`);
11411
+ }
11412
+ return {
11413
+ packageJsonPath,
11414
+ projectSpec,
11415
+ projectManifest,
11416
+ projectKey,
11417
+ packetPath: packet ? packetPath : null,
11418
+ packetVersion,
11419
+ packetVersionIgnored,
11420
+ warnings,
11421
+ };
11422
+ }
11423
+
11424
+ /**
11425
+ * Pure: the pin status from the two sources and the running version. The
11426
+ * message names where each version it quotes was read: the key and manifest of
11427
+ * the project pin, the packet file of the recorded version.
11428
+ */
11429
+ export function evaluatePin({ projectSpec = null, projectManifest = null, projectKey = null, packetVersion = null, packetVersionIgnored = null, packetPath = null, running, force = false }) {
11430
+ const projectVersion = exactPinVersion(projectSpec);
11431
+ const range = projectSpec != null && !projectVersion ? projectSpec : null;
11432
+ const source = projectVersion ? "project" : packetVersion ? "packet" : null;
11433
+ const version = projectVersion || packetVersion || null;
11434
+ const manifest = projectManifest || "package.json";
11435
+ const projectFrom = `${projectKey || "devDependencies"} in ${manifest}`;
11436
+ const packetFrom = `campaigns_os_version in ${packetPath || "the Build Packet"}`;
11437
+ const noPacket = packetVersionIgnored != null
11438
+ ? `campaigns_os_version ${JSON.stringify(packetVersionIgnored)} in ${packetPath || "the Build Packet"} is not a bare x.y.z version and was ignored`
11439
+ : packetPath ? `no campaigns_os_version in ${packetPath}` : "no packet version";
11440
+ let status;
11441
+ let message;
11442
+ if (projectVersion && packetVersion && projectVersion !== packetVersion) {
11443
+ status = "conflicting_pin";
11444
+ message = `conflicting_pin — project pins ${projectVersion} (${projectFrom}), packet records ${packetVersion} (${packetFrom})`;
11445
+ } else if (!version) {
11446
+ status = "unpinned";
11447
+ const project = range != null
11448
+ ? `project range ${range || '""'} (${projectFrom}) is not an exact version`
11449
+ : projectManifest ? `no project pin in ${projectManifest}` : "no package.json found";
11450
+ message = `unpinned (${project}; ${noPacket})`;
11451
+ } else if (version !== running) {
11452
+ status = "stale_pin";
11453
+ message = `stale_pin — ${source === "project" ? `project pins ${version} (${projectFrom})` : `packet records ${version} (${packetFrom})`}, running ${running}`;
11454
+ } else {
11455
+ status = "match";
11456
+ message = `match (${version} — ${source === "project" ? projectFrom : packetFrom})`;
11457
+ }
11458
+ const forced = force && PIN_BLOCKING_STATUSES.has(status);
11459
+ return {
11460
+ source,
11461
+ version,
11462
+ running,
11463
+ status,
11464
+ range,
11465
+ packet_version: packetVersion,
11466
+ packet_version_ignored: packetVersionIgnored,
11467
+ project_version: projectVersion,
11468
+ project_manifest: projectManifest,
11469
+ project_key: projectKey,
11470
+ forced,
11471
+ message: forced ? `${message} (overridden by --force)` : message,
11472
+ };
11473
+ }
11474
+
11475
+ // Each line names the manifest and key the pin (or range) was read from; with
11476
+ // neither, the nearest manifest and devDependencies, where the ADR puts a pin.
11477
+ function pinAction(pin, sources) {
11478
+ const manifest = pin.project_manifest || "the project's package.json";
11479
+ const key = pin.project_key || "devDependencies";
11480
+ const packet = sources.packetPath || "the Build Packet";
11481
+ if (pin.status === "conflicting_pin") {
11482
+ 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).`;
11483
+ }
11484
+ if (pin.source === "project") {
11485
+ 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).`;
11486
+ }
11487
+ const pinStep = pin.project_key
11488
+ ? `set ${key}["${PIN_PACKAGE_NAME}"] in ${manifest} to ${pin.running} (it holds the range ${JSON.stringify(pin.range)})`
11489
+ : `add "${PIN_PACKAGE_NAME}": "${pin.running}" to ${key} in ${manifest}`;
11490
+ 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).`;
11491
+ }
11492
+
11493
+ export function pinTextLines(result) {
11494
+ return result?.pin ? [`Pin: ${result.pin.message}`] : [];
11495
+ }
11496
+
10874
11497
  function toolingCommand(args) {
10875
11498
  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");
11499
+ if (action !== "status") throw refused(`Unknown tooling command: ${action}`);
11500
+ if (args.target === true) throw refused("Missing value for --target");
11501
+ if (args.platform === true) throw refused("Missing value for --platform");
11502
+ if (args["skills-revision"] === true) {
11503
+ // The example is the bundle this CLI ships, read from skills.json, so the
11504
+ // refusal never teaches a revision that has since moved.
11505
+ const shipped = readJson(join(ROOT, "skills.json")).bundle_revision;
11506
+ 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>.`);
11507
+ }
11508
+ // Bare, like --dry-run: the shared parser would read `--force true` as a
11509
+ // value, and an override must never hinge on how a token happened to parse.
11510
+ if (Object.hasOwn(args, "force") && args.force !== true) {
11511
+ throw refused(`--force takes no value (got ${JSON.stringify(args.force)}); write \`--force\` on its own.`);
11512
+ }
11513
+ // The parser keeps `--no-force` as its own key, so without this it would
11514
+ // run as if unsaid and the operator would never learn it did nothing.
11515
+ if (Object.hasOwn(args, "no-force")) {
11516
+ throw refused("--no-force is not a flag of tooling status; --force is bare and off by default");
11517
+ }
11518
+ if (args.packet === true) throw refused("Missing value for --packet");
11519
+ const pinSources = resolvePinSources(args);
10879
11520
 
10880
11521
  const pkg = readJson(join(ROOT, "package.json"));
10881
- const skillStatus = installSkills(args.target, true, args.platform || "all");
11522
+ // An explicit --target or --platform (`all` included) is checked as asked.
11523
+ // Without either, only the platform directories that already hold a
11524
+ // Campaigns OS skill are held to this bundle: the documented install is one
11525
+ // platform, and reading the other two as stale told that operator to install
11526
+ // everywhere and exit 2 on a correct setup.
11527
+ const explicitSkillScope = isNonEmptyString(args.target) || isNonEmptyString(args.platform);
11528
+ const allSkillStatus = installSkills(args.target, true, args.platform || "all");
11529
+ const skillScope = explicitSkillScope
11530
+ ? { status: allSkillStatus, scope: "requested", notInstalled: [] }
11531
+ : scopeSkillStatusToInstalledPlatforms(allSkillStatus);
11532
+ const skillStatus = skillScope.status;
10882
11533
  const skillActions = classifyToolingSkillActions(skillStatus.skills || []);
10883
11534
  const staleSkills = skillActions.actionable;
10884
11535
  const install = localInstallStatus(ROOT, pkg);
@@ -10950,9 +11601,30 @@ function toolingCommand(args) {
10950
11601
  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
11602
  }
10952
11603
 
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.`);
11604
+ if (skillScope.scope === "installed_platforms" && skillScope.notInstalled.length) {
11605
+ const checked = skillStatus.targets.map((target) => target.platform_label).join(", ");
11606
+ const skipped = skillScope.notInstalled.map((target) => target.platform_label);
11607
+ 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).`);
11608
+ }
11609
+
11610
+ if (skillScope.scope === "no_platform_installed") {
11611
+ // Nothing to refresh: the documented install is one platform, the
11612
+ // harness in use, so name the choice rather than installing everywhere.
11613
+ // The command ends its own sentence and is runnable as printed (Claude
11614
+ // Code, the documented install). The other platforms follow in a separate
11615
+ // sentence of prose: no `<a|b>` template or parenthesis a shell would read
11616
+ // as a redirect or a subshell if the command were copied with it.
11617
+ 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.`);
11618
+ } else if (staleSkills.length) {
11619
+ const stalePlatforms = SKILL_PLATFORMS.map((platform) => platform.id)
11620
+ .filter((id) => staleSkills.some((skill) => skill.platform === id));
11621
+ const invocations = args.target
11622
+ ? [["--target", args.target]]
11623
+ : skillScope.scope === "installed_platforms" && stalePlatforms.length < SKILL_PLATFORMS.length
11624
+ ? stalePlatforms.map((platform) => ["--platform", platform])
11625
+ : [["--platform", args.platform || "all"]];
11626
+ const commands = invocations.map((skillArgs) => `${cli.invocation_prefix} install-skills ${skillArgs.join(" ")}`);
11627
+ actions.push(`Refresh installed skills: ${commands.join(" and ")}. Restart local agent sessions afterwards.`);
10956
11628
  }
10957
11629
 
10958
11630
  if (install.mode === "checkout" && cli.global_binary.status === "not_found") {
@@ -10960,8 +11632,8 @@ function toolingCommand(args) {
10960
11632
  } else if (install.mode === "node_modules" && cli.global_binary.status !== "found") {
10961
11633
  // A consumer install runs through npm's bin resolution; no PATH ritual.
10962
11634
  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.`);
11635
+ ? `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.`
11636
+ : `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
11637
  } else if (install.mode !== "checkout" && install.mode !== "npx_cache" && cli.global_binary.status === "not_found") {
10966
11638
  warnings.push(`campaigns-os is not on PATH; call node ${cli.local_bin} directly.`);
10967
11639
  } else if (install.mode !== "checkout" && cli.global_binary.status === "found_other_install") {
@@ -10980,11 +11652,43 @@ function toolingCommand(args) {
10980
11652
  warnings.push("This checkout has uncommitted changes; verify they are intentional before publishing or comparing freshness.");
10981
11653
  }
10982
11654
 
11655
+ // The manifest THIS CLI ships, read from its own package root — never from the
11656
+ // working directory, which may be a campaign repo with no skills.json at all.
11657
+ const skillsRevision = evaluateSkillsRevision(args["skills-revision"], readJsonIfExists(join(ROOT, "skills.json")) || {});
11658
+ if (skillsRevision.status === "mismatch") {
11659
+ 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.`);
11660
+ }
11661
+
11662
+ // The CLI's own package.json is the running version: the executable this
11663
+ // command is, not whichever one the project would have resolved.
11664
+ const pin = evaluatePin({
11665
+ projectSpec: pinSources.projectSpec,
11666
+ projectManifest: pinSources.projectManifest,
11667
+ projectKey: pinSources.projectKey,
11668
+ packetVersion: pinSources.packetVersion,
11669
+ packetVersionIgnored: pinSources.packetVersionIgnored,
11670
+ packetPath: pinSources.packetPath,
11671
+ running: pkg.version,
11672
+ force: args.force === true,
11673
+ });
11674
+ warnings.push(...pinSources.warnings);
11675
+ const pinBlocks = PIN_BLOCKING_STATUSES.has(pin.status) && !pin.forced;
11676
+ if (PIN_BLOCKING_STATUSES.has(pin.status)) {
11677
+ (pin.forced ? warnings : actions).push(pin.forced
11678
+ ? `Pin ${pin.status} overridden by --force; this run proceeds on ${pin.running}.`
11679
+ : pinAction(pin, pinSources));
11680
+ }
11681
+
10983
11682
  const gitBlocks = git.status === "ok" && Number.isFinite(git.behind) && git.behind > 0;
10984
- const ok = !gitBlocks && staleSkills.length === 0;
11683
+ const ok = !gitBlocks && staleSkills.length === 0 && skillsRevision.status !== "mismatch" && !pinBlocks;
10985
11684
  return {
10986
11685
  ok,
10987
11686
  status: ok ? "ready" : "attention_required",
11687
+ // A bare status string, so a consumer can branch on it without reaching into
11688
+ // an object; the detail sits beside it under skills_revision.
11689
+ revision_check: skillsRevision.status,
11690
+ skills_revision: skillsRevision,
11691
+ pin,
10988
11692
  install,
10989
11693
  package: packageStatus,
10990
11694
  git,
@@ -10992,6 +11696,12 @@ function toolingCommand(args) {
10992
11696
  skills: {
10993
11697
  ok: staleSkills.length === 0,
10994
11698
  stale_count: staleSkills.length,
11699
+ // requested: --target/--platform named the scope. installed_platforms:
11700
+ // only platforms with Campaigns OS skills installed were checked, and
11701
+ // not_installed_platforms lists the rest. no_platform_installed: none
11702
+ // had any, so every platform is listed there and was checked.
11703
+ scope: skillScope.scope,
11704
+ not_installed_platforms: skillScope.notInstalled,
10995
11705
  status: skillStatus,
10996
11706
  },
10997
11707
  ready,
@@ -11000,21 +11710,37 @@ function toolingCommand(args) {
11000
11710
  };
11001
11711
  }
11002
11712
 
11713
+ export async function toolingStatusCommand(args, options = {}) {
11714
+ const result = toolingCommand(args);
11715
+ const { gatewayLoginStatus } = await import("./admin-transport.mjs");
11716
+ result.gateway_login = await gatewayLoginStatus(options);
11717
+ const auth = result.gateway_login;
11718
+ if (!auth.accounts.length) result.warnings.push(auth.state === "unavailable"
11719
+ ? "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."
11720
+ : `Gateway login: ${auth.state}. Use ${result.cli.invocation_prefix} login --store <subdomain>.`);
11721
+ 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).`);
11722
+ return result;
11723
+ }
11724
+
11003
11725
  export function toolingDiagnose(args, { runTooling = toolingCommand, runDoctor = doctorCommand } = {}) {
11004
11726
  let tooling = null;
11005
11727
  let doctor = null;
11006
11728
  let inspectionFailed = false;
11007
- const platform = args.platform || "all";
11008
11729
  // Inputs are used only by local producers, never echoed, and mutation flags
11009
11730
  // are not forwarded. Even an exception's message may contain a secret path.
11731
+ // An unnamed platform is not forwarded, so status checks only the platforms
11732
+ // that hold Campaigns OS skills, as it does when run directly.
11010
11733
  try {
11011
- tooling = runTooling({ _: ["tooling", "status"], platform, ...(typeof args.target === "string" ? { target: args.target } : {}) });
11734
+ tooling = runTooling({ _: ["tooling", "status"], ...(args.platform ? { platform: args.platform } : {}), ...(typeof args.target === "string" ? { target: args.target } : {}) });
11012
11735
  } catch { /* unavailable, no raw producer exception in a support export */ }
11013
11736
  if (args.packet !== undefined) {
11014
11737
  try {
11015
11738
  doctor = runDoctor({ packet: args.packet, "no-write": true, ...(typeof args.context === "string" ? { context: args.context } : {}), ...(typeof args.report === "string" ? { report: args.report } : {}) });
11016
11739
  } catch { inspectionFailed = true; }
11017
11740
  }
11741
+ // `installed` only when status actually narrowed to installed platforms; an
11742
+ // unnamed platform that checked every one (nothing installed) stays `all`.
11743
+ const platform = args.platform || (tooling?.skills?.scope === "installed_platforms" ? "installed" : "all");
11018
11744
  return diagnosticExport({ tooling, doctor, platform, inspectionFailed });
11019
11745
  }
11020
11746
 
@@ -11495,7 +12221,7 @@ async function findingsCommand(args, ambient = null) {
11495
12221
  if (sub === "harvest") return findingsHarvest(args, ambient);
11496
12222
  if (sub === "list") return findingsList(args, ambient);
11497
12223
  if (sub === "export") return findingsExport(args, ambient);
11498
- throw new Error(`Unknown findings subcommand "${sub}". Use: add | harvest | list | export.`);
12224
+ throw refused(`Unknown findings subcommand "${sub}". Use: add | harvest | list | export.`);
11499
12225
  }
11500
12226
 
11501
12227
  function resolveFindingsJournalPath(args, ambient = null) {
@@ -11539,7 +12265,7 @@ async function findingsAdd(args, ambient = null) {
11539
12265
  summary = answers.summary;
11540
12266
  details = answers.details;
11541
12267
  } else {
11542
- throw new Error(
12268
+ throw refused(
11543
12269
  `findings add is missing required flags: ${missing.join(", ")}. `
11544
12270
  + `Provide them as flags (flags-first for agents/CI), e.g. `
11545
12271
  + `--stage ${FINDING_STAGES[0]} --kind ${FINDING_KINDS[0]} --summary "..."`,
@@ -11773,7 +12499,7 @@ export async function runSessionCommand(args, ambient = null, sessionHolder = nu
11773
12499
  if (sub === "start") return runSessionStart(args);
11774
12500
  if (sub === "status") return runSessionStatus(args, ambient);
11775
12501
  if (sub === "end") return runSessionEnd(args, ambient, sessionHolder);
11776
- throw new Error(`Unknown run subcommand "${sub}". Use: start | end | status.`);
12502
+ throw refused(`Unknown run subcommand "${sub}". Use: start | end | status.`);
11777
12503
  }
11778
12504
 
11779
12505
  function writeRunSessionResult(result, args, exitCode) {
@@ -11957,6 +12683,12 @@ function runSessionProgress(found) {
11957
12683
  }
11958
12684
 
11959
12685
  async function runSessionEnd(args, ambient = null, sessionHolder = null) {
12686
+ // The closer reads a session, but its own argv refusals still belong to the
12687
+ // invoking command. Check them before entering closeRunSession's nested scope.
12688
+ if (Object.hasOwn(args, "new-run") || Object.hasOwn(args, "run-id")) {
12689
+ throw refused("run end uses the saved session's run ID; --new-run and --run-id are not accepted.");
12690
+ }
12691
+ validateRunRecordArgv(args);
11960
12692
  // Use the session resolved once in main() (single source of truth).
11961
12693
  const found = ambient;
11962
12694
  if (!found) {
@@ -11987,10 +12719,18 @@ async function runSessionEnd(args, ambient = null, sessionHolder = null) {
11987
12719
  // `qa run`'s --base-url, --browser or --test-order say nothing about the
11988
12720
  // record — and run-record stamps the flag NAMES it was given into the Run
11989
12721
  // Record's argv_shape, so carrying them over would file them as run-record's.
11990
- const RUN_RECORD_INHERITABLE_FLAGS = Object.freeze([
12722
+ // `dry-run` is on the list for `run end --dry-run`, the one closer whose
12723
+ // invoking command implements the flag; a closer invoked by a command that
12724
+ // does not (the QA auto-end) drops it from extraArgs before calling — see
12725
+ // DRY_RUN_COMMANDS and autoEndRunSessionAfterTerminalQa.
12726
+ export const RUN_RECORD_INHERITABLE_FLAGS = Object.freeze([
11991
12727
  "context", "report", "qa-verdict", "journal", "surfaces", "primary-surface", "surface-confidence",
11992
12728
  "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",
12729
+ "no-remit", "no-write", "proxy-base", "dry-run", "json",
12730
+ ]);
12731
+ const RUN_RECORD_BOOLEAN_INHERITABLE_FLAGS = new Set(["no-remit", "no-write", "dry-run", "json"]);
12732
+ const RUN_RECORD_INTEGER_INHERITABLE_FLAGS = new Set([
12733
+ "agent-input-tokens", "agent-output-tokens", "agent-tool-output-tokens", "agent-total-tokens", "agent-elapsed-ms",
11994
12734
  ]);
11995
12735
 
11996
12736
  // The run-record argv that closes `session`: its run_id and journal, the
@@ -12018,12 +12758,23 @@ export function runSessionEndArgs(session, packet, extraArgs = {}) {
12018
12758
  async function closeRunSession(found, { packet, extraArgs = {}, silent = false, promptForConsent = true, onError = null } = {}) {
12019
12759
  const endArgs = runSessionEndArgs(found.session, packet, extraArgs);
12020
12760
  try {
12021
- const summary = await runRecordCommand(endArgs, found, { silent, promptForConsent });
12022
- clearRunSession(found.path);
12761
+ // No internal closeout currently constructs a refusal before its invoking
12762
+ // command journals: the sweep forwards only remit controls tolerated by
12763
+ // run-record, run end validates its own argv first, and QA auto-end runs
12764
+ // after QA persistence. Keep the scope at this boundary so a future
12765
+ // closeout refusal swallowed by onError cannot mark the outer invocation.
12766
+ const summary = await runWithRefusalScope(() => runRecordCommand(endArgs, found, { silent, promptForConsent }));
12767
+ // Clearing the session is a write like any other, so a closer carrying
12768
+ // --dry-run leaves it open: the operator sees the record the close would
12769
+ // assemble and can still close for real afterwards.
12770
+ if (endArgs["dry-run"] !== true) clearRunSession(found.path);
12023
12771
  return summary;
12024
12772
  } catch (error) {
12025
- if (!onError) throw error;
12026
- onError(error);
12773
+ // A nested run-record refusal is a failure of the invoking closer, which
12774
+ // has already read session state. Do not pass its tag to outer persistence.
12775
+ const failure = error?.code === REFUSED_INVOCATION ? new Error(error.message, { cause: error }) : error;
12776
+ if (!onError) throw failure;
12777
+ onError(failure);
12027
12778
  return null;
12028
12779
  }
12029
12780
  }
@@ -12041,11 +12792,34 @@ async function closeRunSession(found, { packet, extraArgs = {}, silent = false,
12041
12792
  // forms. `run status` never sweeps — it is read-only.
12042
12793
  // Best-effort throughout: a closeout failure clears the file and says so on
12043
12794
  // stderr; it never blocks the command that triggered it.
12795
+ //
12796
+ // The sweep is an effect of the command that triggers it, and it runs BEFORE
12797
+ // dispatch — so it happens even when the argv that follows is refused. That is
12798
+ // deliberate (the stale session at the target is closed out either way), but it
12799
+ // makes the sweep the one place where `--no-write` could still write: it
12800
+ // assembles a Run Record and removes the session file. `--no-write` writes
12801
+ // nothing, the closeout included; see the guard below.
12044
12802
  const STALE_SWEEP_TARGET_COMMANDS = new Set(["start", "prepare-build", "build"]);
12045
12803
 
12046
12804
  async function closeOutStaleRunSessions(command, args) {
12047
12805
  // A command that opted out of sessions altogether must not sweep either.
12048
12806
  if (args["no-run-session"] === true) return [];
12807
+ // --no-write leaves the tree byte-identical. Inheriting the flag into the
12808
+ // closeout was not enough: it suppressed the Run Record but clearRunSession
12809
+ // still deleted the session file, so `--no-write` moved bytes. Skip the
12810
+ // sweep entirely instead — the stale session stays for the next run that
12811
+ // does write.
12812
+ if (args["no-write"] === true) return [];
12813
+ // Nor may a dry run sweep. The closeout writes a Run Record, deletes the
12814
+ // session file and (under consent) sends a remit — every effect --dry-run
12815
+ // promises not to have. The sweep runs BEFORE dispatch, so it was doing all
12816
+ // three for commands that implement the flag: `run end --dry-run --json`
12817
+ // over an aged session exited 0, wrote a record, POSTed once and removed the
12818
+ // session. --dry-run means "show me, do nothing"; the stale session simply
12819
+ // stays stale until a real invocation closes it. Gated on the same predicate
12820
+ // persistLifecycleIfRequested uses, so a stray --dry-run on a command that
12821
+ // does not implement it changes nothing here either.
12822
+ if (args["dry-run"] === true && commandImplementsDryRun(command, args)) return [];
12049
12823
  const roots = [];
12050
12824
  if (STALE_SWEEP_TARGET_COMMANDS.has(command) && optionalString(args.target)) roots.push(resolve(args.target));
12051
12825
  if (command === "run" && (args._[1] === "start" || args._[1] === "end")) {
@@ -12062,8 +12836,11 @@ async function closeOutStaleRunSessions(command, args) {
12062
12836
  }
12063
12837
  }
12064
12838
  // 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.
12839
+ // --no-remit stays an opt-out, and a run pointed at a custom --proxy-base
12840
+ // never remits the stale record to the canonical endpoint. --no-write is
12841
+ // carried too, though the guard above means it never arrives true: if the
12842
+ // sweep ever becomes conditional rather than skipped, the closeout must
12843
+ // still see it.
12067
12844
  const inherited = {};
12068
12845
  for (const flag of ["no-remit", "no-write", "proxy-base"]) {
12069
12846
  if (args[flag] !== undefined) inherited[flag] = args[flag];
@@ -12207,6 +12984,14 @@ export function describeCampaignKeyRejection(rejected) {
12207
12984
  // docs/workflow-findings-sidecar.md.
12208
12985
  async function runRecordCommand(args, ambient = null, { silent = false, promptForConsent = true } = {}) {
12209
12986
  const packetPath = resolve(requireArg(args, "packet"));
12987
+ if (args["new-run"] === true && optionalString(args["run-id"])) {
12988
+ throw refused("run-record: --new-run and --run-id are exclusive; --run-id names the run to re-emit, --new-run mints a fresh one.");
12989
+ }
12990
+ const agentUsage = refusing(() => parseAgentUsageArgs(args));
12991
+ // --dry-run assembles the record and shows it, then writes and sends
12992
+ // nothing. It differs from --no-write, which skips the assembly's reads
12993
+ // as well; combining the two is allowed and still writes nothing.
12994
+ const dryRun = isDryRun(args);
12210
12995
  const parsedSurfaces = parseRunRecordSurfaces(args.surfaces);
12211
12996
  const packet = readJson(packetPath);
12212
12997
  const explicitTargetRepo = resolveFromFile(packetPath, packet.assembly?.target_repo);
@@ -12230,10 +13015,12 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
12230
13015
  : inferQaVerdictPath({ packet, report, reportPath: reportExists ? reportPath : null, targetRepo, baseDir });
12231
13016
  const qaVerdictExists = qaVerdictPath != null && existsSync(qaVerdictPath);
12232
13017
  const qaVerdict = qaVerdictExists ? readJson(qaVerdictPath) : null;
13018
+ if (qaVerdict && (packet.spec?.local_spec_id != null || qaVerdict.local_spec_id != null) && !qaVerdictIdentityMatch(qaVerdict, packet)) {
13019
+ throw new Error("run-record: QA verdict does not match this packet's local_spec_id; foreign evidence cannot be recorded as this local campaign.");
13020
+ }
12233
13021
 
12234
13022
  const journalPath = resolveJournalPath(args);
12235
13023
  const journal = readJournal(journalPath);
12236
- const agentUsage = parseAgentUsageArgs(args);
12237
13024
 
12238
13025
  // run_id: explicit flag > --new-run (mint) > active run session > the most
12239
13026
  // recent Run Record on disk for this packet's campaign > freshly minted.
@@ -12246,9 +13033,6 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
12246
13033
  // record for this campaign exists, or when --new-run asks for it. The
12247
13034
  // source travels on the stdout envelope only: the record's schema is
12248
13035
  // hashed surface and does not carry it.
12249
- if (args["new-run"] === true && optionalString(args["run-id"])) {
12250
- throw new Error("run-record: --new-run and --run-id are exclusive; --run-id names the run to re-emit, --new-run mints a fresh one.");
12251
- }
12252
13036
  const listOnly = args.list === true;
12253
13037
  // The directory is scanned only when something reads it: --list, or an id
12254
13038
  // that nothing else names. An explicit --run-id, --new-run or an open
@@ -12307,6 +13091,7 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
12307
13091
  run_id_source: runIdSource,
12308
13092
  records,
12309
13093
  remit: { result: null, http_status: null, base_kind: null, sent: false, preserved: false },
13094
+ ...(dryRun ? { dry_run: true, would_write: null, would_remit: null } : {}),
12310
13095
  };
12311
13096
  if (silent) return summary;
12312
13097
  if (args.json) {
@@ -12373,7 +13158,11 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
12373
13158
  }
12374
13159
  if (existsSync(journalPath)) artifacts.push(runRecordArtifactRef("findings_journal", journalPath, WORKFLOW_FINDING_SCHEMA, baseDir));
12375
13160
 
12376
- const write = args["no-write"] !== true;
13161
+ // What a real run of this command line would write, and what this one does:
13162
+ // a dry run assembles and validates the record a real run would write
13163
+ // (assertRunRecordValid), and stops at the write itself (writeRunRecord).
13164
+ const wouldWrite = args["no-write"] !== true;
13165
+ const write = wouldWrite && !dryRun;
12377
13166
  // The verdict publish outcome this record carries: the session's attempt
12378
13167
  // for the verdict being recorded (the auto-end and `run end` both close
12379
13168
  // through here with the session still ambient), else the newest attempt
@@ -12388,8 +13177,10 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
12388
13177
  // refuses a second send, so a record it already has is final: it is neither
12389
13178
  // re-sent nor rewritten here. A prior send that did not land (failed,
12390
13179
  // 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;
13180
+ // may not. A pure --no-write run reads nothing: it writes and sends nothing.
13181
+ // A --dry-run does read it, because what a real run would do here is the
13182
+ // very thing the dry run is being asked to report.
13183
+ const prior = write || dryRun ? readPriorRunRecord(runId, baseDir) : null;
12393
13184
  const priorRemit = priorRemitOutcome(prior?.record);
12394
13185
  const storedRemotely = priorRemit?.state === "ok";
12395
13186
  // A pure local-inspection run (--no-write), an explicit --no-remit, or a
@@ -12410,6 +13201,16 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
12410
13201
  announceDefaultOnTelemetry(consent.scope || proxyBase);
12411
13202
  }
12412
13203
 
13204
+ // Under --dry-run the remit is disabled by construction (write is false), so
13205
+ // `remitDisabled` cannot say whether a real run would have sent. This does:
13206
+ // the same three stoppers minus the dry run itself, against the consent this
13207
+ // machine actually resolved.
13208
+ const wouldRemit = dryRun
13209
+ && args["no-remit"] !== true
13210
+ && args["no-write"] !== true
13211
+ && !storedRemotely
13212
+ && consent.state === "on";
13213
+
12413
13214
  // A publish the record already says landed is never downgraded by a
12414
13215
  // reassembly: the prior ok block wins over a session attempt that did not
12415
13216
  // land, mirroring the remit carry-forward below.
@@ -12428,6 +13229,7 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
12428
13229
  consent: { state: consent.state, source: consent.source },
12429
13230
  identity: {
12430
13231
  map_id: optionalString(packet.spec?.map_id),
13232
+ ...localSpecIdentityFields(packet.spec),
12431
13233
  campaign_slug: optionalString(packet.campaign?.public_route_slug),
12432
13234
  template_family: optionalString(packet.assembly?.template_family),
12433
13235
  entry_point_shape: "packet",
@@ -12446,9 +13248,6 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
12446
13248
  agentUsage,
12447
13249
  });
12448
13250
 
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
13251
  const shouldAttemptRemit = !remitDisabled && consent.state === "on";
12453
13252
  const remitBaseKind = describeRemitBaseKind(proxyBase);
12454
13253
 
@@ -12467,6 +13266,9 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
12467
13266
  record: prior.record,
12468
13267
  run_id_source: runIdSource,
12469
13268
  remit: { result: REMIT_RESULTS.not_contacted, http_status: null, base_kind: null, sent: false, preserved: true },
13269
+ // A record the receiver already holds is final: a real run of this same
13270
+ // command line would write nothing and send nothing either.
13271
+ ...(dryRun ? { dry_run: true, would_write: null, would_remit: null } : {}),
12470
13272
  };
12471
13273
  if (silent) return summary;
12472
13274
  if (args.json) {
@@ -12504,7 +13306,11 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
12504
13306
  record.remit_base_kind = carriedForward.base_kind;
12505
13307
  }
12506
13308
 
12507
- const recordPath = write ? writeRunRecord(record, { baseDir }) : null;
13309
+ // The record is validated whenever a real run of this command line would
13310
+ // write one — under --dry-run too — and always BEFORE the remit below, so an
13311
+ // invalid record refuses ahead of any send on both paths. The write itself is
13312
+ // separate and happens after the remit; validating here does not write.
13313
+ if (wouldWrite) assertRunRecordValid(record);
12508
13314
 
12509
13315
  // Remit is consent-gated, non-fatal, bounded, and keyed on run_id — the
12510
13316
  // receiver holds one record per id and refuses a second POST for one it
@@ -12514,7 +13320,10 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
12514
13320
  // The key is only resolved when a send will actually be attempted: under
12515
13321
  // consent-off or --no-remit nothing goes out, so nothing is read and nothing
12516
13322
  // is said about a credential.
12517
- const keySource = shouldAttemptRemit ? resolveCampaignsApiKeySource(packet, packetPath, process.env) : { key: null, rejected: null };
13323
+ // A dry run resolves it too, and only when a real run would have: the
13324
+ // destination gate below names the credential that would travel, and the
13325
+ // preview must name the one the real send would.
13326
+ const keySource = shouldAttemptRemit || wouldRemit ? resolveCampaignsApiKeySource(packet, packetPath, process.env) : { key: null, rejected: null };
12518
13327
  const campaignKey = keySource.key;
12519
13328
  // A refused key is not a missing key. Say so on stderr, naming the source
12520
13329
  // and not the value, so the operator fixes the credential instead of reading
@@ -12524,7 +13333,30 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
12524
13333
  // send's outcome, which is only known below; the credential itself never
12525
13334
  // leaves the machine.
12526
13335
  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`);
13336
+ 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`);
13337
+
13338
+ // The transport's own preconditions, run for a dry run as well: `remit`
13339
+ // demands a fetch to send with, and refuses a base that is not https (nor a
13340
+ // loopback host), BEFORE it opens a socket — so a send the real run could
13341
+ // never have made must not be previewed as one it would post. Same
13342
+ // functions, in the transport's order, with the same label and the same
13343
+ // credential wording the remit below hands them. The remit rail is non-fatal
13344
+ // by contract — the real run classifies either refusal as a failed remit and
13345
+ // still exits 0 — so the dry run reports it on the envelope and exits 0 too.
13346
+ let wouldRemitEndpoint = null;
13347
+ let wouldRemitRefusal = null;
13348
+ if (wouldRemit) {
13349
+ try {
13350
+ assertFetchAvailable(globalThis.fetch);
13351
+ const { base } = assertSecureProxyBase(proxyBase, {
13352
+ label: "Run Telemetry remit",
13353
+ credential: typeof campaignKey === "string" && campaignKey.trim() ? "the campaign key" : null,
13354
+ });
13355
+ wouldRemitEndpoint = `${base}${DEFAULT_RUNS_ENDPOINT}`;
13356
+ } catch (error) {
13357
+ wouldRemitRefusal = String(error?.message ?? error);
13358
+ }
13359
+ }
12528
13360
  const remitStatus = shouldAttemptRemit
12529
13361
  ? await remitRunRecord(record, { proxyBase, consent, campaignKey })
12530
13362
  : { attempted: false, ok: null, error: null, endpoint: null, result: null, http_status: null };
@@ -12543,7 +13375,11 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
12543
13375
  record.remit_base_kind = remitStatus.attempted ? remitBaseKind : null;
12544
13376
  }
12545
13377
 
12546
- if (write) writeRunRecord(record, { baseDir });
13378
+ // The record's one and only write, after the remit so the file that lands
13379
+ // carries this run's remit_* outcome rather than the placeholders it was
13380
+ // assembled with. Validated above, ahead of the send; nothing is written
13381
+ // under --dry-run or --no-write.
13382
+ const recordPath = write ? writeRunRecord(record, { baseDir }) : null;
12547
13383
 
12548
13384
  const summary = {
12549
13385
  ok: true,
@@ -12569,6 +13405,13 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
12569
13405
  sent: remitStatus.attempted,
12570
13406
  preserved: Boolean(carriedForward),
12571
13407
  },
13408
+ // What a real run of this same command line — the one without --dry-run —
13409
+ // would have done: the record file it would write, and the endpoint it
13410
+ // would POST to (null when --no-remit, --no-write or consent would have
13411
+ // stopped the send anyway, and null with `would_remit_refused` set when the
13412
+ // transport's destination gate refuses the base before any socket opens).
13413
+ // Envelope only; never on the record.
13414
+ ...(dryRun ? { dry_run: true, would_write: resolveRunRecordPath(runId, baseDir), would_remit: wouldRemitEndpoint, would_remit_refused: wouldRemitRefusal } : {}),
12572
13415
  };
12573
13416
  if (silent) return summary;
12574
13417
  if (args.json) {
@@ -12589,10 +13432,33 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
12589
13432
  console.log(`Remit: skipped (consent ${record.consent_state}${remitDisabled ? ", disabled for this run" : ""}).`);
12590
13433
  }
12591
13434
  if (write) console.log(`Wrote: ${recordPath}`);
12592
- else console.log("Dry run only (--no-write). No record written, no remit.");
13435
+ else if (dryRun) {
13436
+ console.log("Dry run (--dry-run). Nothing written, nothing sent.");
13437
+ console.log(`Would write: ${summary.would_write}`);
13438
+ 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)")}`);
13439
+ } else console.log("Dry run only (--no-write). No record written, no remit.");
12593
13440
  return summary;
12594
13441
  }
12595
13442
 
13443
+ /**
13444
+ * The Run Record's refusal, lifted out of the write. `writeRunRecord` validates
13445
+ * the record before it writes and refuses an invalid one; that refusal belongs
13446
+ * to the command rather than to the write, because it has to fire on the dry
13447
+ * path (which never writes) and ahead of the remit on the real one (which
13448
+ * writes only afterwards). Both modes therefore refuse the same record with the
13449
+ * same message and the same exit code, before any send: the check fires here,
13450
+ * never on the identical one inside run-record.mjs (which stays as the writer's
13451
+ * own last guard). The validator is that writer's — imported, not re-stated —
13452
+ * so the two cannot disagree about what a valid record is. Writes nothing.
13453
+ */
13454
+ function assertRunRecordValid(record) {
13455
+ const validation = validateRunRecord(record);
13456
+ if (!validation.ok) {
13457
+ const detail = validation.errors.map((error) => `[${error.code}] ${error.message}`).join("; ");
13458
+ throw new Error(`Run Record failed validation; refusing to write: ${detail}`);
13459
+ }
13460
+ }
13461
+
12596
13462
  // The record already written under `runId` for this target, or null when there
12597
13463
  // is none, it cannot be parsed, or it is not a valid Run Record. Only a record
12598
13464
  // `writeRunRecord` could have written is trusted as a prior — the same
@@ -12697,7 +13563,8 @@ function inferQaVerdictPath({ packet, report, reportPath = null, targetRepo = nu
12697
13563
  report,
12698
13564
  reportPath: reportPath || (targetRepo ? campaignSidecarPaths(targetRepo).reportPath : null),
12699
13565
  roots: [targetRepo, baseDir],
12700
- }).filter((candidate) => candidate.verdict && candidate.trusted);
13566
+ }).filter((candidate) => candidate.verdict && candidate.trusted
13567
+ && ((packet?.spec?.local_spec_id == null && candidate.verdict.local_spec_id == null) || candidate.identityMatch));
12701
13568
  eligible.sort((a, b) => {
12702
13569
  const scoreDelta = qaVerdictCandidateScore(b, packet) - qaVerdictCandidateScore(a, packet);
12703
13570
  if (scoreDelta !== 0) return scoreDelta;
@@ -12733,11 +13600,31 @@ function parseRunRecordSurfaces(value) {
12733
13600
  const surfaces = parseCommaList(value);
12734
13601
  const unknown = surfaces.filter((surface) => !RUN_RECORD_SURFACES.includes(surface));
12735
13602
  if (unknown.length) {
12736
- throw new Error(`Unknown --surfaces value(s): ${unknown.join(", ")}. Use one of: ${RUN_RECORD_SURFACES.join(", ")}.`);
13603
+ throw refused(`Unknown --surfaces value(s): ${unknown.join(", ")}. Use one of: ${RUN_RECORD_SURFACES.join(", ")}.`);
12737
13604
  }
12738
13605
  return surfaces;
12739
13606
  }
12740
13607
 
13608
+ function validateRunRecordArgv(args) {
13609
+ for (const flag of RUN_RECORD_INHERITABLE_FLAGS) {
13610
+ // The integer flags are checked below by parseAgentUsageArgs, which keeps
13611
+ // their non-negative-integer diagnostics for bare and blank values.
13612
+ if (!RUN_RECORD_BOOLEAN_INHERITABLE_FLAGS.has(flag) && !RUN_RECORD_INTEGER_INHERITABLE_FLAGS.has(flag) && Object.hasOwn(args, flag)) requireArg(args, flag);
13613
+ }
13614
+ if (Object.hasOwn(args, "run-id")) requireArg(args, "run-id");
13615
+ if (Object.hasOwn(args, "new-run") && args["new-run"] !== true) {
13616
+ throw refused("--new-run takes no value.");
13617
+ }
13618
+ if (args["new-run"] === true && optionalString(args["run-id"])) {
13619
+ throw refused("run-record: --new-run and --run-id are exclusive; --run-id names the run to re-emit, --new-run mints a fresh one.");
13620
+ }
13621
+ return {
13622
+ agentUsage: refusing(() => parseAgentUsageArgs(args)),
13623
+ dryRun: isDryRun(args),
13624
+ parsedSurfaces: parseRunRecordSurfaces(args.surfaces),
13625
+ };
13626
+ }
13627
+
12741
13628
  function parseAgentUsageArgs(args) {
12742
13629
  const fields = {
12743
13630
  "agent-input-tokens": "input_tokens",
@@ -12823,6 +13710,21 @@ function toolkitProvenance({ silent = false } = {}) {
12823
13710
  // canonical endpoint, or against --proxy-base when given, so it reports what
12824
13711
  // a remit to that endpoint would do. `off` takes no --proxy-base: an OFF
12825
13712
  // choice is machine-wide and the record it writes carries no scope.
13713
+ // `assertSecureProxyBase` is SHARED with the remit rail, where it runs in the
13714
+ // middle of a handler: `remit()` reaches it after the Run Record has been built
13715
+ // and written, and `spec derive --write-map` after the derive produced one. A
13716
+ // throw from those positions is a handler failure — the most valuable lifecycle
13717
+ // entry there is — so the refusal tag cannot live inside the validator.
13718
+ //
13719
+ // The call sites below are the up-front ones: the base comes straight off argv
13720
+ // and is checked before any configuration read or write, before a credential is
13721
+ // attached, and before a request. The tag therefore goes HERE, where the
13722
+ // position is known — `refusing()` is the shared form of that contract. The
13723
+ // message and the exit code stay the validator's own; only the verdict the
13724
+ // lifecycle journal reads is added.
13725
+ const refuseInsecureProxyBase = (proxyBase, options) =>
13726
+ refusing(() => assertSecureProxyBase(proxyBase, options));
13727
+
12826
13728
  async function telemetryCommand(args) {
12827
13729
  const sub = args._[1] || "status";
12828
13730
  const configPath = resolveConfigPath();
@@ -12831,17 +13733,17 @@ async function telemetryCommand(args) {
12831
13733
  // empty variable) is not "no flag": treating it as absent would grant or
12832
13734
  // check the canonical endpoint under a request that named something else.
12833
13735
  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.`);
13736
+ throw refused(`telemetry ${sub}: --proxy-base needs a URL (https, or a loopback host); nothing was written.`);
12835
13737
  }
12836
13738
  // Same transport rule as the remit rail: https, or a loopback host. A grant
12837
13739
  // for a base a remit would refuse to send to is not a grant, and a status
12838
13740
  // check against one would report on a remit that can never happen. Nothing
12839
13741
  // is sent here, so the in-clear warning is left to the remit.
12840
- const secureBase = () => assertSecureProxyBase(requestedBase, { label: `telemetry ${sub}`, warn: () => {} }).base;
13742
+ const secureBase = () => refuseInsecureProxyBase(requestedBase, { label: `telemetry ${sub}`, warn: () => {} }).base;
12841
13743
 
12842
13744
  if (sub === "on" || sub === "off") {
12843
13745
  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)}`);
13746
+ 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
13747
  }
12846
13748
  const proxyBase = requestedBase ? secureBase() : DEFAULT_PROXY_BASE;
12847
13749
  const { configPath: written, config } = writeConsentConfig(sub, { configPath, proxyBase, source: "telemetry-command" });
@@ -12917,7 +13819,7 @@ async function telemetryCommand(args) {
12917
13819
 
12918
13820
  if (sub === "list") return telemetryList(args);
12919
13821
 
12920
- throw new Error(`Unknown telemetry subcommand "${sub}". Use: status | on | off | list.`);
13822
+ throw refused(`Unknown telemetry subcommand "${sub}". Use: status | on | off | list.`);
12921
13823
  }
12922
13824
 
12923
13825
  // `telemetry list` — the reader that never existed. Since the receiver
@@ -12932,14 +13834,16 @@ const TELEMETRY_LIST_TIMEOUT_MS = 15_000;
12932
13834
 
12933
13835
  const TELEMETRY_LIST_MAX_BODY_BYTES = 4_000_000; // the receiver caps a listing at 500 summaries
12934
13836
 
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+.");
13837
+ export async function telemetryList(args, { fetchImpl = globalThis.fetch } = {}) {
13838
+ if (typeof fetchImpl !== "function") throw refused("Global fetch is not available. Upgrade to Node 18+.");
12937
13839
  // 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(
13840
+ // loud warning that the credential is in clear. Anything else is refused
13841
+ // here, before a credential is attached to a request and before the packet
13842
+ // below is read — the same up-front position the gate holds under
13843
+ // `telemetry status|on`, so it is tagged the same way. The gate's own
13844
+ // normalized base is what the consent scope and the request URL below are
13845
+ // built from, so one string decides both.
13846
+ const { url: proxyUrl, base: proxyBase, loopback } = refuseInsecureProxyBase(
12943
13847
  optionalString(args["proxy-base"]) || DEFAULT_PROXY_BASE,
12944
13848
  { label: "telemetry list", credential: "the listing credential (the ops admin key, or the packet's campaign key)" },
12945
13849
  );
@@ -12951,9 +13855,11 @@ async function telemetryList(args, { fetchImpl = globalThis.fetch } = {}) {
12951
13855
  const { key, rejected } = resolveCampaignsApiKeySource(packet, packetPath, process.env);
12952
13856
  const rejection = describeCampaignKeyRejection(rejected);
12953
13857
  // 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.`);
13858
+ // the operator can fix it, and nothing is sent in the meantime. Every
13859
+ // refusal ahead of the request is tagged, so the lifecycle journal records
13860
+ // nothing for it — the same rule as a flag refused up front.
13861
+ if (rejection) throw refused(`telemetry list --packet: ${rejection}`);
13862
+ 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
13863
  headers["X-Campaign-Key"] = key;
12958
13864
  scope = "tenant";
12959
13865
  } else {
@@ -12963,11 +13869,12 @@ async function telemetryList(args, { fetchImpl = globalThis.fetch } = {}) {
12963
13869
  // posture remit takes with default-on consent.
12964
13870
  const canonical = normalizeConsentScope(proxyBase) === CANONICAL_REMIT_SCOPE;
12965
13871
  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.`);
13872
+ 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
13873
  }
12968
13874
  const envName = optionalString(args["admin-key-env"]) || DEFAULT_ADMIN_KEY_ENV;
12969
13875
  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.`);
13876
+ 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.`);
13877
+ 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
13878
  headers["X-Campaigns-Ops-Admin-Key"] = adminKey.trim();
12972
13879
  scope = "admin";
12973
13880
  }
@@ -13291,7 +14198,8 @@ export function resultTextLines(result, { headerLines = [] } = {}) {
13291
14198
  // A waive command's second line names what it recorded; the third is the
13292
14199
  // stage doctor now picks for the report the waiver was written to.
13293
14200
  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}` : ""}`);
14201
+ 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}` : ""}`);
14202
+ if (result.dry_run) lines.push(`Would write: ${result.would_write} (nothing was written)`);
13295
14203
  if (result.next_stage) lines.push(`Next stage: ${result.next_stage}${result.next_stage_reason ? ` (${result.next_stage_reason})` : ""}`);
13296
14204
  }
13297
14205
  if (result.targets?.length) {
@@ -13386,13 +14294,13 @@ export async function specDeriveWithMapWriteback(args, { fetchImpl = undefined,
13386
14294
  // by a stray value. `--proxy-base` names a URL or is refused here, before
13387
14295
  // anything is read, as `telemetry` refuses a bare one.
13388
14296
  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.`);
14297
+ throw refused(`--write-map takes no value (got ${JSON.stringify(args["write-map"])}); write \`--write-map\` on its own, after the other flags.`);
13390
14298
  }
13391
14299
  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.");
14300
+ throw refused("spec derive: --proxy-base needs a URL (https, or a loopback host); nothing was written.");
13393
14301
  }
13394
14302
  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.");
14303
+ throw refused("spec derive: --proxy-base only applies with --write-map; nothing was written.");
13396
14304
  }
13397
14305
  const writeMap = args["write-map"] === true;
13398
14306
  const localArgs = Object.fromEntries(Object.entries(args).filter(([key]) => !SPEC_DERIVE_MAP_FLAGS.includes(key)));