@nextcommerce/campaigns-os 1.43.1 → 1.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/AGENTS.md +9 -2
  2. package/CHANGELOG.md +1099 -5103
  3. package/README.md +34 -13
  4. package/agents/claude/CLAUDE.md +1 -1
  5. package/agents/codex/AGENTS.md +1 -1
  6. package/agents/copilot/copilot-instructions.md +1 -1
  7. package/agents/cursor/campaigns-os.mdc +1 -1
  8. package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +2 -2
  9. package/campaign-spec/dist/rules/analytics-contract-shape.js +2 -2
  10. package/campaign-spec/dist/rules/store-profile-shape.d.ts +5 -1
  11. package/campaign-spec/dist/rules/store-profile-shape.js +8 -10
  12. package/campaign-spec/dist/types.d.ts +2 -2
  13. package/contracts/agent-relevant-change-policy.v1.json +5 -0
  14. package/contracts/archive/CHANGELOG.2026-09-30.md +5111 -0
  15. package/contracts/archive/release-ledger.2026-09-30.json +5068 -0
  16. package/contracts/effects.v1.json +1184 -121
  17. package/contracts/orientation-reason-codes.v1.json +7 -0
  18. package/contracts/release-ledger.json +2190 -5260
  19. package/contracts/supported-surface.json +7 -4
  20. package/contracts/template-slot-manifest.shared-content-core.v0.json +403 -0
  21. package/docs/brand-theme-bridge.md +81 -0
  22. package/docs/build-packet.md +222 -23
  23. package/docs/campaigns-os-build-flow.md +4 -3
  24. package/docs/design-source-package.md +162 -15
  25. package/docs/effects.md +66 -12
  26. package/docs/gateway-login.md +3 -0
  27. package/docs/local-setup.md +1 -1
  28. package/docs/orientation-contract-reference.md +42 -2
  29. package/docs/polish-evidence.md +74 -0
  30. package/docs/progress-snapshots.md +10 -6
  31. package/docs/qa-and-test-orders.md +230 -20
  32. package/docs/release-ledger-authoring-guide.md +70 -8
  33. package/docs/runtime-readiness.md +1 -1
  34. package/docs/sdk-storage-compatibility.md +1 -1
  35. package/docs/skills-revision.md +10 -10
  36. package/docs/supported-surface.md +2 -2
  37. package/docs/versioning.md +4 -1
  38. package/package.json +1 -1
  39. package/schemas/campaigns-os-release-ledger.v1.schema.json +32 -2
  40. package/schemas/campaigns-os-tooling-orientation.v1.schema.json +1 -0
  41. package/skills/campaign-lifecycle-orientation/SKILL.md +16 -5
  42. package/skills/campaign-readback-classification/SKILL.md +3 -3
  43. package/skills/campaign-run-evidence/SKILL.md +7 -6
  44. package/skills/contribution-intake/SKILL.md +3 -3
  45. package/skills/next-campaigns-build/SKILL.md +7 -6
  46. package/skills/next-campaigns-os/SKILL.md +7 -7
  47. package/skills/next-campaigns-os/references/session-intake.md +9 -3
  48. package/skills/next-campaigns-os-setup/SKILL.md +5 -5
  49. package/skills/next-campaigns-polish/SKILL.md +28 -9
  50. package/skills/next-campaigns-qa/SKILL.md +7 -4
  51. package/skills.json +10 -10
  52. package/src/brand-theme.mjs +320 -20
  53. package/src/build-brief.mjs +6 -4
  54. package/src/built-script-syntax.mjs +480 -0
  55. package/src/built-site-scope.mjs +16 -4
  56. package/src/campaigns-api-key.mjs +99 -0
  57. package/src/cli-helpers.mjs +118 -0
  58. package/src/cli.mjs +1530 -7580
  59. package/src/commercial-parity.mjs +48 -2
  60. package/src/design-source-package.mjs +1 -1
  61. package/src/design-source-publication.mjs +898 -0
  62. package/src/deviation.mjs +13 -1
  63. package/src/diagnostic.mjs +6 -2
  64. package/src/directory-lock.mjs +270 -0
  65. package/src/doctor/checks.mjs +4654 -0
  66. package/src/doctor/inspect.mjs +678 -0
  67. package/src/doctor/next-step.mjs +731 -0
  68. package/src/doctor/source-provenance.mjs +184 -0
  69. package/src/install-invocation.mjs +29 -0
  70. package/src/invocation.mjs +183 -0
  71. package/src/live-campaign-refs.mjs +466 -0
  72. package/src/login.mjs +2 -2
  73. package/src/page-kit-store-profile.mjs +69 -12
  74. package/src/page-kit-sync.mjs +31 -12
  75. package/src/private-template-source.mjs +1 -1
  76. package/src/progress-node.mjs +9 -36
  77. package/src/proof-policy.mjs +1 -1
  78. package/src/qa-analytics-correctness.mjs +3 -0
  79. package/src/qa-binding-evidence.mjs +76 -11
  80. package/src/qa-browser.mjs +1316 -105
  81. package/src/qa-build-scope.mjs +47 -0
  82. package/src/qa-commercial-parity.mjs +48 -5
  83. package/src/qa-node.mjs +339 -19
  84. package/src/qa-test-order-topology.mjs +148 -0
  85. package/src/sdk-markup.mjs +72 -8
  86. package/src/source-html-intake.mjs +117 -1
  87. package/src/source-html-manifest.mjs +9 -2
  88. package/src/stage-ledger.mjs +28 -0
  89. package/src/stage-record.mjs +551 -0
  90. package/src/target-lock.mjs +54 -0
  91. package/src/template-brand-contract.mjs +17 -1
  92. package/src/upsell-selector-scope.mjs +112 -2
@@ -0,0 +1,184 @@
1
+ // Source-provenance checkpoint (#534). A CampaignSpec page whose design_source
2
+ // is Figma makes doctor demand figma-sections-export provenance from the
3
+ // source-html manifest (source_html.producer_provenance*). When the approved
4
+ // source for that page is hand-written HTML and the Figma file is only a
5
+ // render of it, no export exists to supply that provenance. This gate lets a
6
+ // named human record that, per page, with a reason and a bound, through
7
+ // `checkpoint waive --gate source_html.producer_provenance --page <page_id>`.
8
+ //
9
+ // A waiver never suppresses a finding. Doctor reports the provenance findings
10
+ // once per Figma-typed page: as warnings carrying `waived: true` for a waived
11
+ // page, and as errors for an unwaived one. The family is the
12
+ // source_html.producer_provenance* codes plus source_html.files.partial and
13
+ // source_html.files.asset, the export's own file-inventory shape, which only
14
+ // a Figma-typed page is held to. When the manifest itself claims to be a
15
+ // figma-sections-export output, the findings are the manifest's own: they
16
+ // stay manifest-wide errors, the gate is not waivable, and any waiver
17
+ // recorded for the page is inert. Every other source check (manifest
18
+ // validity, wrapper policy, screenshot proof) is evaluated elsewhere and is
19
+ // untouched by this gate.
20
+
21
+ import {
22
+ assessCheckpointWaivers,
23
+ checkpointStateFingerprint,
24
+ projectCheckpointWaiverAssessment,
25
+ } from "../checkpoint-waiver.mjs";
26
+
27
+ export const SOURCE_PROVENANCE_SCOPE = "source_html.producer_provenance";
28
+
29
+ function isPlainObject(value) {
30
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
31
+ }
32
+
33
+ function emptyWaiverAssessment() {
34
+ return { active: null, inert_counts: { stale: 0, foreign: 0, malformed: 0, expired: 0 } };
35
+ }
36
+
37
+ // True when a source-html manifest's generator names figma-sections-export in
38
+ // any form: bare, `@<version>` or another suffix, any case, surrounding
39
+ // whitespace. Such a manifest claims to be a real export, so its provenance
40
+ // findings are its own and no page waiver clears them. Every reader of the
41
+ // generator claim uses this one predicate.
42
+ export function generatorClaimsFigmaExport(generator) {
43
+ if (typeof generator !== "string") return false;
44
+ return /(^|[^a-z0-9_-])figma-sections-export(?![a-z0-9_-])/i.test(generator.trim());
45
+ }
46
+
47
+ export function isSourceProvenanceCode(code) {
48
+ const value = String(code || "");
49
+ return value === SOURCE_PROVENANCE_SCOPE || value.startsWith(`${SOURCE_PROVENANCE_SCOPE}.`);
50
+ }
51
+
52
+ // The Figma-export file inventory: a manifest behind a Figma-typed page must
53
+ // list section partials and exported assets. Hand-written HTML has neither, so
54
+ // these two findings belong to the waivable family.
55
+ export const FIGMA_EXPORT_FILE_CODES = Object.freeze(["source_html.files.partial", "source_html.files.asset"]);
56
+
57
+ // The gate code when the manifest's generator claims figma-sections-export:
58
+ // blocked, not waivable, and any waiver recorded for the page is inert.
59
+ export const SOURCE_PROVENANCE_EXPORTER_CLAIM_CODE = `${SOURCE_PROVENANCE_SCOPE}.exporter_claim`;
60
+
61
+ function waiveCommand(pageId) {
62
+ return `campaigns-os checkpoint waive --packet <packet> --gate ${SOURCE_PROVENANCE_SCOPE} --page ${pageId} --reason "<reason>" --waived-by "<named human>" --expires-at <ISO>`;
63
+ }
64
+
65
+ /**
66
+ * One checkpoint gate per Figma-typed page.
67
+ *
68
+ * @param {{
69
+ * pages: Array<{ page_id: string, design_source: { type: string|null, file_url: string|null } }>,
70
+ * blockingCodes: string[],
71
+ * generatorClaimsExport?: boolean,
72
+ * waivers?: unknown,
73
+ * now?: string,
74
+ * }} input
75
+ */
76
+ export function evaluateSourceProvenanceGates({ pages = [], blockingCodes = [], generatorClaimsExport = false, waivers = null, now = new Date().toISOString() } = {}) {
77
+ const records = (Array.isArray(waivers) ? waivers : [])
78
+ .filter((record) => isPlainObject(record) && record.scope === SOURCE_PROVENANCE_SCOPE);
79
+ const figmaPageIds = new Set(pages.map((page) => page.page_id));
80
+ const findings = [...new Set(blockingCodes)].sort();
81
+ let exporterClaimRecords = 0;
82
+
83
+ const gates = pages.map((page) => {
84
+ const subject = { page_id: page.page_id };
85
+ const state = { design_source: page.design_source, findings };
86
+ const base = {
87
+ id: SOURCE_PROVENANCE_SCOPE,
88
+ scope: SOURCE_PROVENANCE_SCOPE,
89
+ subject,
90
+ state,
91
+ };
92
+ if (findings.length === 0) {
93
+ return {
94
+ ...base,
95
+ status: "pass",
96
+ code: `${SOURCE_PROVENANCE_SCOPE}.pass`,
97
+ reason: `Page "${page.page_id}" has a Figma design source and the source-html manifest carries semantic figma-sections-export provenance.`,
98
+ waivable: false,
99
+ state_fingerprint: null,
100
+ waiver: null,
101
+ waiver_assessment: emptyWaiverAssessment(),
102
+ required_actions: [],
103
+ };
104
+ }
105
+ // Only this page's records are assessed here. Another page's waiver is
106
+ // not "foreign" history for this page; it belongs to that page's gate.
107
+ const pageRecords = records.filter((record) => isPlainObject(record.subject) && record.subject.page_id === page.page_id);
108
+ // An exporter claim takes precedence over every other inert kind: it makes
109
+ // each of this page's records inert whatever else is true of it (stale,
110
+ // expired, malformed), so each is counted once, as exporter_claim, and
111
+ // never assessed.
112
+ if (generatorClaimsExport) {
113
+ exporterClaimRecords += pageRecords.length;
114
+ return {
115
+ ...base,
116
+ status: "blocked",
117
+ code: SOURCE_PROVENANCE_EXPORTER_CLAIM_CODE,
118
+ reason: `Page "${page.page_id}" has a Figma design source and the source-html manifest's generator claims figma-sections-export, but the manifest lacks that export's provenance (${findings.join(", ")}). A real export carries it, so this is not waivable; re-run figma-sections-export, or, when the approved source is hand-written HTML, set the manifest's generator to name the real producer and record a page waiver.`,
119
+ waivable: false,
120
+ state_fingerprint: null,
121
+ waiver: null,
122
+ waiver_assessment: emptyWaiverAssessment(),
123
+ required_actions: [
124
+ {
125
+ id: "repair_target",
126
+ kind: "manual",
127
+ command: null,
128
+ description: "Re-run figma-sections-export so the source-html manifest carries semantic producer_provenance, or correct the manifest's generator when no export produced it, then re-run doctor.",
129
+ },
130
+ ],
131
+ };
132
+ }
133
+ const state_fingerprint = checkpointStateFingerprint({ scope: SOURCE_PROVENANCE_SCOPE, subject, state });
134
+ const checkpoint = { scope: SOURCE_PROVENANCE_SCOPE, subject, state_fingerprint };
135
+ const waiver_assessment = projectCheckpointWaiverAssessment(
136
+ assessCheckpointWaivers(pageRecords, checkpoint, { now }),
137
+ checkpoint,
138
+ );
139
+ const waiver = waiver_assessment.active;
140
+ return {
141
+ ...base,
142
+ status: waiver ? "waived" : "blocked",
143
+ code: waiver ? `${SOURCE_PROVENANCE_SCOPE}.waived` : SOURCE_PROVENANCE_SCOPE,
144
+ reason: waiver
145
+ ? `Page "${page.page_id}" has a Figma design source but no figma-sections-export provenance (${findings.join(", ")}); accepted under a named-human decision that its approved source is hand-written HTML.`
146
+ : `Page "${page.page_id}" has a Figma design source, so doctor requires figma-sections-export provenance in the source-html manifest (${findings.join(", ")}). Re-run figma-sections-export, or, when the approved source is hand-written HTML and the Figma file only renders it, record a named-human waiver for this page.`,
147
+ waivable: true,
148
+ state_fingerprint,
149
+ waiver,
150
+ waiver_assessment,
151
+ required_actions: waiver ? [] : [
152
+ {
153
+ id: "repair_target",
154
+ kind: "manual",
155
+ command: null,
156
+ description: "Re-run figma-sections-export for this page so the source-html manifest carries semantic producer_provenance, then re-run doctor.",
157
+ },
158
+ {
159
+ id: "waive_checkpoint",
160
+ kind: "command",
161
+ command: waiveCommand(page.page_id),
162
+ description: `When page "${page.page_id}"'s approved source is hand-written HTML, record a named-human waiver with a reason and a bound. Manifest, wrapper-policy and screenshot-proof checks still apply.`,
163
+ },
164
+ ],
165
+ };
166
+ });
167
+
168
+ // Records naming a page that no longer has a Figma design source (the
169
+ // page's design_source changed, or the page left the spec) can never
170
+ // satisfy a gate again.
171
+ const noFigmaSource = records.filter((record) => !isPlainObject(record.subject)
172
+ || !figmaPageIds.has(record.subject.page_id));
173
+ const counts = { stale: 0, foreign: 0, malformed: 0, expired: 0 };
174
+ for (const gate of gates) {
175
+ for (const kind of Object.keys(counts)) counts[kind] += gate.waiver_assessment?.inert_counts?.[kind] || 0;
176
+ }
177
+ counts.no_figma_source = noFigmaSource.length;
178
+ counts.exporter_claim = exporterClaimRecords;
179
+ const inertPages = [...new Set(noFigmaSource
180
+ .map((record) => (isPlainObject(record.subject) && typeof record.subject.page_id === "string" ? record.subject.page_id : null))
181
+ .filter(Boolean))].sort();
182
+
183
+ return { gates, inert: { counts, pages: inertPages } };
184
+ }
@@ -0,0 +1,29 @@
1
+ // How this install spells the commands it prints. ROOT resolves `..` from this
2
+ // file, so the module must stay directly under src/ to name the package root.
3
+ import { dirname, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { invocationPrefixFor } from "./install-mode.mjs";
6
+
7
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
8
+
9
+ // Every command this CLI PRODUCES for an operator or agent to copy is spelled
10
+ // once, here, for the install it runs from (see install-mode.mjs): bare
11
+ // `campaigns-os` from a checkout, `npx --no-install campaigns-os` from a
12
+ // campaign folder that pins the toolkit, `npx --yes <spec>` from an npx cache.
13
+ // Result payloads are never rewritten after the fact — a path, a quoted
14
+ // argument or a data value that happens to contain the words is left exactly
15
+ // as it is.
16
+ function cmd(verb, rest = "") {
17
+ const prefix = invocationPrefixFor(ROOT);
18
+ return `${prefix} ${verb}${rest ? ` ${rest}` : ""}`;
19
+ }
20
+
21
+ // A registry command (gate actions, checkpoint remediations) is stored in its
22
+ // canonical bare form so internal bookkeeping can match on it; this spells
23
+ // it for the current install at the moment it is emitted.
24
+ function asInvocation(command) {
25
+ if (typeof command !== "string" || !command.startsWith("campaigns-os ")) return command;
26
+ return `${invocationPrefixFor(ROOT)} ${command.slice("campaigns-os ".length)}`;
27
+ }
28
+
29
+ export { ROOT, cmd, asInvocation };
@@ -0,0 +1,183 @@
1
+ // Invocation policy: which cross-cutting steps run around a command, and in
2
+ // what order. One owner for rules that used to be restated at each step — the
3
+ // pre-dispatch bypasses, the stale-session sweep and its root, the ambient
4
+ // run-session read, the lifecycle wrapper, the journal exemptions, the
5
+ // commands that implement --dry-run, and the QA auto-end trigger.
6
+ //
7
+ // The declaration below is data: a class, a sweep root kind and a few flags
8
+ // per command, and per subcommand only where the subcommand differs from its
9
+ // command. It describes kernel behaviour and is never a permission
10
+ // interpreter: contracts/effects.v1.json is bound to it by a test, never read
11
+ // here. The steps themselves (the sweep, the ambient read, the journal append,
12
+ // the auto-end, every handler) stay in cli.mjs and are handed to
13
+ // runInvocation() as functions; this module decides only whether and when each
14
+ // runs. It imports nothing from cli.mjs.
15
+
16
+ import { runWithRefusalScope, withCommandLifecycle } from "./lifecycle.mjs";
17
+
18
+ // Frozen all the way down, so a caller handed a subcommand list cannot edit it.
19
+ const frozen = (value) => { if (value && typeof value === "object") Object.values(value).forEach(frozen); return Object.freeze(value); };
20
+
21
+ // The steps each pre-dispatch class runs. `auth` and `inline` commands run
22
+ // their handler in place: no sweep, no ambient read, no wrapper, no journal.
23
+ // `inspection` still resolves the ambient session and is wrapped, but never
24
+ // sweeps or journals. `projection` (`readback`) is wrapped only: its --packet
25
+ // is an override naming the Build Packet to project, not a session locator.
26
+ // Read as one, the named file was loaded whole past readback's own size bound,
27
+ // and a valid override exited 1 whenever some active session was bound to a
28
+ // different packet; neither belongs to a command declared read-only.
29
+ const CLASS_STEPS = frozen({
30
+ auth: [], inline: [],
31
+ inspection: ["wrapper", "ambient"],
32
+ projection: ["wrapper"],
33
+ standard: ["wrapper", "ambient", "sweep", "journal"],
34
+ });
35
+
36
+ // Every top-level command, in the order did-you-mean breaks ties. Fields:
37
+ // `class` (default "standard"); `sweepRoot` ("target": the --target directory,
38
+ // "session": the run-session root; default none); `dryRun` (the command
39
+ // implements --dry-run); `journalExempt`; `journalExemptWhen` (exempt when the
40
+ // `given` flag is set and the `unlessBare` flag is not bare: an inspection must
41
+ // not append to a delivered campaign's active run);
42
+ // `subcommands` (the only subcommand names that resolve; others inherit the
43
+ // command's entry and are refused by the handler).
44
+ //
45
+ // `dryRun` marks only the commands that IMPLEMENT the flag. It reaches every
46
+ // handler through a permissive parseArgs, and an exemption scoped to the flag
47
+ // alone once fired on commands that ignore it: `qa run --dry-run` placed orders
48
+ // while writing no journal entry, and carried the flag into its own auto-end,
49
+ // which assembled no Run Record and left the session open. A command without
50
+ // `dryRun` given --dry-run journals if it otherwise would, and is not refused.
51
+ const COMMANDS = frozen({
52
+ help: { journalExempt: true },
53
+ login: { class: "auth" },
54
+ logout: { class: "auth" },
55
+ demo: { class: "inline" },
56
+ tooling: { subcommands: ["diagnose", "setup", "status"] },
57
+ sdk: { subcommands: ["storage-check"] },
58
+ readback: { class: "projection" },
59
+ start: { sweepRoot: "target" },
60
+ "prepare-build": { sweepRoot: "target" },
61
+ build: { sweepRoot: "target" },
62
+ doctor: { journalExemptWhen: { given: "packet", unlessBare: "write" } },
63
+ bundle: { subcommands: ["check"] },
64
+ standardize: {},
65
+ theme: { subcommands: ["generate", "inspect", "waive"] },
66
+ checkpoint: { subcommands: ["waive"] },
67
+ polish: { subcommands: ["capture"] },
68
+ record: { subcommands: ["build", "polish", "setup"] },
69
+ "validate-assembly-report": {},
70
+ "install-agent-context": { dryRun: true },
71
+ "install-skills": { dryRun: true },
72
+ "page-kit": { subcommands: ["parity", "sync"] },
73
+ spec: { subcommands: ["derive"] },
74
+ next: { subcommands: ["build", "deploy", "polish", "qa", "setup"] },
75
+ qa: { subcommands: ["install-browser", "parity", "policy", "promote", "publish", "resolve", "run", "waive"] },
76
+ findings: { subcommands: ["add", "export", "harvest", "list"] },
77
+ "run-record": { dryRun: true },
78
+ telemetry: { subcommands: ["list", "off", "on", "status"] },
79
+ run: { subcommands: ["end", "start", "status"] },
80
+ });
81
+
82
+ // Where a subcommand's policy differs from its command's entry. Matched on the
83
+ // explicit subcommand token only: bare `run` defaults to `status` inside its
84
+ // handler, but is not `run status` here, so it journals.
85
+ const SUBCOMMAND_OVERRIDES = frozen({
86
+ "tooling diagnose": { class: "inline" },
87
+ "tooling setup": { class: "inline" },
88
+ "sdk storage-check": { class: "inspection" },
89
+ "theme waive": { dryRun: true },
90
+ "checkpoint waive": { dryRun: true },
91
+ "page-kit sync": { dryRun: true },
92
+ "spec derive": { dryRun: true },
93
+ "qa publish": { dryRun: true },
94
+ "record build": { dryRun: true },
95
+ "record polish": { dryRun: true },
96
+ "record setup": { dryRun: true },
97
+ "qa run": { autoEnd: true },
98
+ "run start": { sweepRoot: "session" },
99
+ "run end": { sweepRoot: "session", dryRun: true },
100
+ "run status": { journalExempt: true },
101
+ });
102
+
103
+ export const commandNames = () => Object.keys(COMMANDS);
104
+
105
+ export const subcommandNames = (command) => (Object.hasOwn(COMMANDS, command) && COMMANDS[command].subcommands) || frozen([]);
106
+
107
+ // A run opted out of sessions altogether: no stale sweep, and no intake
108
+ // auto-start (which reads this predicate from here).
109
+ export const optsOutOfRunSession = (args) => args["no-run-session"] === true;
110
+
111
+ // The policy for one invocation. Two --dry-run predicates are deliberate and
112
+ // differ: on a command implementing the flag, its PRESENCE suppresses the
113
+ // sweep (a valued flag the handler will refuse still does nothing first), while
114
+ // only a bare `--dry-run` exempts the journal (the install commands accept a
115
+ // valued flag as a dry run and still journal it). The sweep writes a Run
116
+ // Record, deletes the session file and (under consent) remits — every effect
117
+ // --dry-run promises not to have — so the stale session stays stale until a
118
+ // real invocation closes it. --no-write suppresses both too: inheriting it
119
+ // into the closeout suppressed the Run Record but still deleted the session
120
+ // file. A refused invocation never journals either; that rule is per outcome,
121
+ // not per argv, and stays with the journal append.
122
+ export function resolveInvocationPolicy(command, args) {
123
+ const subcommand = subcommandNames(command).includes(args._[1]) ? args._[1] : null;
124
+ const rule = { class: "standard", ...(Object.hasOwn(COMMANDS, command) && COMMANDS[command]), ...(subcommand && SUBCOMMAND_OVERRIDES[`${command} ${subcommand}`]) };
125
+ const steps = CLASS_STEPS[rule.class];
126
+ const noWrite = args["no-write"] === true;
127
+ const implementsDryRun = rule.dryRun === true;
128
+ const sweepSuppressed = optsOutOfRunSession(args) || noWrite || (Object.hasOwn(args, "dry-run") && implementsDryRun);
129
+ const inspection = Boolean(rule.journalExemptWhen && args[rule.journalExemptWhen.given] && args[rule.journalExemptWhen.unlessBare] !== true);
130
+ return Object.freeze({
131
+ class: rule.class,
132
+ wrapper: steps.includes("wrapper"), ambient: steps.includes("ambient"),
133
+ sweepRoot: steps.includes("sweep") && !sweepSuppressed ? rule.sweepRoot || null : null,
134
+ journalExempt: !steps.includes("journal") || rule.journalExempt === true || noWrite || (args["dry-run"] === true && implementsDryRun) || inspection,
135
+ implementsDryRun, autoEnd: rule.autoEnd === true,
136
+ });
137
+ }
138
+
139
+ // The sequence main() delegates to. `steps` are cli.mjs mechanisms:
140
+ // dispatch(command, args, { recorder?, ambient?, sessionHolder? }?)
141
+ // closeOutStaleRunSessions(rootKind, args) -> the closed-out stale sessions
142
+ // ambientRunSession(args) -> the active session, or null
143
+ // lifecycleIdentity(args, ambient) -> { argvShape, runId }
144
+ // persistLifecycle(args, command, lifecycle, sessionHolder, thrown)
145
+ // autoEndAfterQa(args, sessionHolder, thrown, implementsDryRun)
146
+ // Order: normalise argv, open the refusal scope, run an unwrapped class in
147
+ // place, else sweep, read the ambient session, and wrap dispatch; on finish
148
+ // (success or error) journal unless exempt, then run the QA auto-end. The
149
+ // sweep precedes the ambient read so a stale session at the root is closed out
150
+ // (findRunSession ignores it) rather than abandoned with no Run Record. The
151
+ // wrapper re-throws unchanged, so the exit code is the command's own; a
152
+ // command that throws is recorded too. Persistence stays opt-in: with no
153
+ // --lifecycle-journal, CAMPAIGNS_OS_LIFECYCLE_LOG or active session nothing is
154
+ // written.
155
+ export async function runInvocation(args, steps) {
156
+ // `npx … campaigns-os <command>` hands the bin its own name as the first
157
+ // positional: it is the program name, not a command.
158
+ if (args._[0] === "campaigns-os") args._.shift();
159
+ const command = args._[0] || "help";
160
+ const policy = resolveInvocationPolicy(command, args);
161
+ // One refusal scope per invocation, so a refusal is visible only to this
162
+ // invocation's persistence step.
163
+ return runWithRefusalScope(async () => {
164
+ if (!policy.wrapper) return steps.dispatch(command, args);
165
+ const sweptStale = policy.sweepRoot ? await steps.closeOutStaleRunSessions(policy.sweepRoot, args) : [];
166
+ const ambient = policy.ambient ? steps.ambientRunSession(args) : null;
167
+ // Per invocation, never module state: an intake that opens or joins a run
168
+ // session mid-command publishes it here, so this command's own entry is
169
+ // persisted into it.
170
+ const sessionHolder = { current: ambient, autoStarted: false, adopted: false, qaResult: null, sweptStale };
171
+ await withCommandLifecycle(
172
+ {
173
+ command,
174
+ ...steps.lifecycleIdentity(args, ambient),
175
+ onFinish: async (lifecycle, thrown) => {
176
+ if (!policy.journalExempt) steps.persistLifecycle(args, command, lifecycle, sessionHolder, thrown);
177
+ if (policy.autoEnd) await steps.autoEndAfterQa(args, sessionHolder, thrown, policy.implementsDryRun);
178
+ },
179
+ },
180
+ (recorder) => steps.dispatch(command, args, { recorder, ambient, sessionHolder }),
181
+ );
182
+ });
183
+ }