@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.
- package/AGENTS.md +9 -2
- package/CHANGELOG.md +1099 -5103
- package/README.md +34 -13
- package/agents/claude/CLAUDE.md +1 -1
- package/agents/codex/AGENTS.md +1 -1
- package/agents/copilot/copilot-instructions.md +1 -1
- package/agents/cursor/campaigns-os.mdc +1 -1
- package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +2 -2
- package/campaign-spec/dist/rules/analytics-contract-shape.js +2 -2
- package/campaign-spec/dist/rules/store-profile-shape.d.ts +5 -1
- package/campaign-spec/dist/rules/store-profile-shape.js +8 -10
- package/campaign-spec/dist/types.d.ts +2 -2
- package/contracts/agent-relevant-change-policy.v1.json +5 -0
- package/contracts/archive/CHANGELOG.2026-09-30.md +5111 -0
- package/contracts/archive/release-ledger.2026-09-30.json +5068 -0
- package/contracts/effects.v1.json +1184 -121
- package/contracts/orientation-reason-codes.v1.json +7 -0
- package/contracts/release-ledger.json +2190 -5260
- package/contracts/supported-surface.json +7 -4
- package/contracts/template-slot-manifest.shared-content-core.v0.json +403 -0
- package/docs/brand-theme-bridge.md +81 -0
- package/docs/build-packet.md +222 -23
- package/docs/campaigns-os-build-flow.md +4 -3
- package/docs/design-source-package.md +162 -15
- package/docs/effects.md +66 -12
- package/docs/gateway-login.md +3 -0
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +42 -2
- package/docs/polish-evidence.md +74 -0
- package/docs/progress-snapshots.md +10 -6
- package/docs/qa-and-test-orders.md +230 -20
- package/docs/release-ledger-authoring-guide.md +70 -8
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/docs/supported-surface.md +2 -2
- package/docs/versioning.md +4 -1
- package/package.json +1 -1
- package/schemas/campaigns-os-release-ledger.v1.schema.json +32 -2
- package/schemas/campaigns-os-tooling-orientation.v1.schema.json +1 -0
- package/skills/campaign-lifecycle-orientation/SKILL.md +16 -5
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +7 -6
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +7 -6
- package/skills/next-campaigns-os/SKILL.md +7 -7
- package/skills/next-campaigns-os/references/session-intake.md +9 -3
- package/skills/next-campaigns-os-setup/SKILL.md +5 -5
- package/skills/next-campaigns-polish/SKILL.md +28 -9
- package/skills/next-campaigns-qa/SKILL.md +7 -4
- package/skills.json +10 -10
- package/src/brand-theme.mjs +320 -20
- package/src/build-brief.mjs +6 -4
- package/src/built-script-syntax.mjs +480 -0
- package/src/built-site-scope.mjs +16 -4
- package/src/campaigns-api-key.mjs +99 -0
- package/src/cli-helpers.mjs +118 -0
- package/src/cli.mjs +1530 -7580
- package/src/commercial-parity.mjs +48 -2
- package/src/design-source-package.mjs +1 -1
- package/src/design-source-publication.mjs +898 -0
- package/src/deviation.mjs +13 -1
- package/src/diagnostic.mjs +6 -2
- package/src/directory-lock.mjs +270 -0
- package/src/doctor/checks.mjs +4654 -0
- package/src/doctor/inspect.mjs +678 -0
- package/src/doctor/next-step.mjs +731 -0
- package/src/doctor/source-provenance.mjs +184 -0
- package/src/install-invocation.mjs +29 -0
- package/src/invocation.mjs +183 -0
- package/src/live-campaign-refs.mjs +466 -0
- package/src/login.mjs +2 -2
- package/src/page-kit-store-profile.mjs +69 -12
- package/src/page-kit-sync.mjs +31 -12
- package/src/private-template-source.mjs +1 -1
- package/src/progress-node.mjs +9 -36
- package/src/proof-policy.mjs +1 -1
- package/src/qa-analytics-correctness.mjs +3 -0
- package/src/qa-binding-evidence.mjs +76 -11
- package/src/qa-browser.mjs +1316 -105
- package/src/qa-build-scope.mjs +47 -0
- package/src/qa-commercial-parity.mjs +48 -5
- package/src/qa-node.mjs +339 -19
- package/src/qa-test-order-topology.mjs +148 -0
- package/src/sdk-markup.mjs +72 -8
- package/src/source-html-intake.mjs +117 -1
- package/src/source-html-manifest.mjs +9 -2
- package/src/stage-ledger.mjs +28 -0
- package/src/stage-record.mjs +551 -0
- package/src/target-lock.mjs +54 -0
- package/src/template-brand-contract.mjs +17 -1
- 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
|
+
}
|