@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/remit.mjs CHANGED
@@ -81,6 +81,22 @@ export function assertSecureProxyBase(proxyBase, { label = "Remit", credential =
81
81
  throw new Error(`${label}: --proxy-base must be https (or a loopback host for local testing); declining to send ${subject} over ${url.protocol}//${url.host}.`);
82
82
  }
83
83
 
84
+ /**
85
+ * The transport's OTHER precondition, checked before the destination gate: a
86
+ * fetch to send with. Exported because a preview must make it too — `qa
87
+ * publish --dry-run` and `run-record --dry-run` call this exact function, so a
88
+ * runtime without a global fetch refuses the dry path by the same message the
89
+ * real send throws, instead of previewing a POST that cannot be made.
90
+ * Throws; returns nothing. There is no default: the caller passes the fetch it
91
+ * would actually send with, so `assertFetchAvailable(undefined)` is a refusal
92
+ * rather than a silent fallback to the global that may not be there either.
93
+ */
94
+ export function assertFetchAvailable(fetchImpl) {
95
+ if (typeof fetchImpl !== "function") {
96
+ throw new Error("Global fetch is not available. Upgrade to Node 18+ or pass fetchImpl.");
97
+ }
98
+ }
99
+
84
100
  function byteLength(value) {
85
101
  return Buffer.byteLength(String(value), "utf8");
86
102
  }
@@ -158,9 +174,7 @@ export async function remit(path, payload, proxyBase, {
158
174
  credential = undefined,
159
175
  onResponse = null,
160
176
  } = {}) {
161
- if (typeof fetchImpl !== "function") {
162
- throw new Error("Global fetch is not available. Upgrade to Node 18+ or pass fetchImpl.");
163
- }
177
+ assertFetchAvailable(fetchImpl);
164
178
  // What the gate says must match what this request actually carries. A caller
165
179
  // that names its credential wins; otherwise infer it from the headers this
166
180
  // module knows carry one (never from a bare Accept or Content-Type), so a
@@ -1,3 +1,4 @@
1
+ import { campaignIdentitiesMatch } from "./spec-source-identity.mjs";
1
2
  // Run Record closeout recognition.
2
3
  //
3
4
  // `next` at stage "done" used to demand a Run Record unconditionally, because
@@ -93,13 +94,11 @@ function qaVerdictDigests(record) {
93
94
  * satisfy closeout — and `run-record` never re-emits under its id.
94
95
  */
95
96
  export function identityMatches(record, packet) {
96
- const mapId = text(packet?.spec?.map_id);
97
97
  const slug = text(packet?.campaign?.public_route_slug);
98
98
  const identity = isObject(record?.identity) ? record.identity : {};
99
- const recordMapId = text(identity.map_id);
100
99
  const recordSlug = text(identity.campaign_slug);
101
- if (!mapId || !slug || !recordMapId || !recordSlug) return false;
102
- return recordMapId === mapId && recordSlug === slug;
100
+ if (!slug || !recordSlug) return false;
101
+ return campaignIdentitiesMatch(identity, packet?.spec) && recordSlug === slug;
103
102
  }
104
103
 
105
104
  function outcome(reason_code, detail, entry = null) {
@@ -1,3 +1,4 @@
1
+ import { localSpecIdentityFields, resolveCampaignIdentity } from "./spec-source-identity.mjs";
1
2
  // Run Telemetry — per-run Run Record capture for Campaigns OS.
2
3
  // See docs/workflow-findings-sidecar.md (Run Telemetry).
3
4
  //
@@ -155,6 +156,8 @@ export function validateRunRecord(record) {
155
156
  if (record.identity != null) {
156
157
  if (typeof record.identity !== "object" || Array.isArray(record.identity)) {
157
158
  add("record.identity", "identity must be an object when present.");
159
+ } else if (record.identity.local_spec_id != null && resolveCampaignIdentity(record.identity)?.kind !== "local_spec") {
160
+ add("record.identity.local_spec_id", "local_spec_id must be a canonical local ID with no saved Map identity.");
158
161
  }
159
162
  }
160
163
 
@@ -449,6 +452,7 @@ export function selectRunFindingIds(journal, runId) {
449
452
  function normalizeIdentity(identity = {}) {
450
453
  return {
451
454
  map_id: identity.map_id ?? null,
455
+ ...localSpecIdentityFields(identity),
452
456
  campaign_slug: identity.campaign_slug ?? null,
453
457
  template_family: identity.template_family ?? null,
454
458
  entry_point_shape: identity.entry_point_shape ?? null,
@@ -84,6 +84,27 @@ function identityValuesAgree(identityField, left, right) {
84
84
  }
85
85
 
86
86
  function compareIdentity(errors, records, identityField) {
87
+ const local = identityField.local_spec_alternative;
88
+ if (local && records.get("build_packet")?.value?.spec?.local_spec_id != null) {
89
+ compareIdentity(errors, records, local);
90
+ // A local identity can never borrow a Map identity from another artifact.
91
+ for (const [kind, path] of Object.entries(identityField.artifact_paths)) {
92
+ if (kind === "qa_verdict") continue; // campaign_slug is the local QA storage key.
93
+ if (valueAt(records.get(kind)?.value, path) != null) {
94
+ errors.push(artifactFinding("bundle.identity.map_id_mismatch", kind,
95
+ "Local-spec bundle carries a saved Map identity.", "Regenerate artifacts from the same local CampaignSpec."));
96
+ }
97
+ }
98
+ return;
99
+ }
100
+ if (local) {
101
+ for (const [kind, path] of Object.entries(local.artifact_paths)) {
102
+ if (valueAt(records.get(kind)?.value, path) != null) {
103
+ errors.push(artifactFinding("bundle.identity.local_spec_id_mismatch", kind,
104
+ "Saved-Map bundle carries local-spec evidence.", "Regenerate artifacts from the same saved Map."));
105
+ }
106
+ }
107
+ }
87
108
  const label = identityField.name;
88
109
  const present = [];
89
110
  for (const [kind, path] of Object.entries(identityField.artifact_paths)) {
@@ -0,0 +1,44 @@
1
+ // Stable campaign identity is separate from both the public route and the
2
+ // material spec hash. Local IDs never identify a saved Map or a portal URL.
3
+ export const LOCAL_SPEC_ID_PATTERN = "^[A-Za-z0-9_-]{1,64}$";
4
+ const localIdPattern = new RegExp(LOCAL_SPEC_ID_PATTERN);
5
+ const text = value => typeof value === "string" && value.trim() ? value.trim() : null;
6
+
7
+ export function campaignSpecIdentity(spec) {
8
+ return {
9
+ map_id: spec?.spec_identity?.map_id ?? spec?.map_id ?? null,
10
+ local_spec_id: spec?.spec_identity?.local_spec_id ?? null,
11
+ };
12
+ }
13
+
14
+ export function resolveCampaignIdentity(fields) {
15
+ // Saved Map IDs retain their existing whitespace normalization. Local IDs
16
+ // are canonical, repository-owned tokens: never trim one into another ID.
17
+ const mapId = text(fields?.map_id);
18
+ const localId = fields?.local_spec_id;
19
+ if (localId != null) {
20
+ if (fields?.map_id != null || typeof localId !== "string" || !localIdPattern.test(localId)) return null;
21
+ return { kind: "local_spec", id: localId };
22
+ }
23
+ return mapId ? { kind: "saved_map", id: mapId } : null;
24
+ }
25
+
26
+ export function campaignIdentitiesMatch(left, right) {
27
+ const a = resolveCampaignIdentity(left);
28
+ const b = resolveCampaignIdentity(right);
29
+ return !!a && !!b && a.kind === b.kind && a.id === b.id;
30
+ }
31
+
32
+ export function localSpecIdentityFields(fields) {
33
+ if (fields?.local_spec_id == null) return {};
34
+ const identity = resolveCampaignIdentity(fields);
35
+ // Omitting a malformed marker would let a conflicting identity fall back to
36
+ // its Map ID. Writers must refuse it rather than silently change its kind.
37
+ if (identity?.kind !== "local_spec") throw new Error("Invalid local_spec_id or conflicting saved Map identity.");
38
+ return { local_spec_id: identity.id };
39
+ }
40
+
41
+ export function localQaIdentifier(localSpecId) {
42
+ if (typeof localSpecId !== "string" || !localIdPattern.test(localSpecId)) throw new Error("Invalid local_spec_id.");
43
+ return `local-spec-${localSpecId}`;
44
+ }
@@ -1,3 +1,4 @@
1
+ import { campaignIdentitiesMatch } from "./spec-source-identity.mjs";
1
2
  import { existsSync, readFileSync } from "node:fs";
2
3
  import { markDoctorSidecarStale, writeDoctorSidecar, writeJsonAtomic } from "./doctor-sidecar.mjs";
3
4
  import { STATUS as QA_STATUS } from "./qa-verdict.mjs";
@@ -377,7 +378,9 @@ export function qaGatePassedForCurrentBuild(report, gate, { buildFingerprint })
377
378
  */
378
379
  export function assemblyReportMatchesPacket(report, packet) {
379
380
  return isPlainObject(report)
380
- && optionalString(report?.identity?.map_id) === optionalString(packet?.spec?.map_id)
381
+ && (report?.identity?.local_spec_id != null || packet?.spec?.local_spec_id != null
382
+ ? campaignIdentitiesMatch(report?.identity, packet?.spec)
383
+ : optionalString(report?.identity?.map_id) === optionalString(packet?.spec?.map_id))
381
384
  && optionalString(report?.identity?.public_route_slug) === optionalString(packet?.campaign?.public_route_slug);
382
385
  }
383
386
 
@@ -0,0 +1,160 @@
1
+ import { existsSync, lstatSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
2
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
3
+
4
+ const PACKAGE = "@nextcommerce/campaigns-os";
5
+ const CONTEXT = ".campaign-runtime/agent-context/CLAUDE.md";
6
+ const IMPORT = `@${CONTEXT}`;
7
+
8
+ // Setup composes the existing installers after npm has installed the project
9
+ // dependencies. It never chooses a campaign, scaffolds pages, or opens a run.
10
+ export function setupArguments(args, argv) {
11
+ const values = new Set(["target", "platform"]);
12
+ const flags = new Set(["dry-run", "json"]);
13
+ const seen = new Set();
14
+ const tokens = argv[0] === "campaigns-os" ? argv.slice(1) : argv;
15
+ for (let i = 2; i < tokens.length; i++) {
16
+ const token = tokens[i];
17
+ const key = token.startsWith("--") ? token.slice(2) : "";
18
+ if ((!values.has(key) && !flags.has(key)) || seen.has(key)) {
19
+ throw new Error(`tooling setup: unsupported or repeated argument ${JSON.stringify(token)}.`);
20
+ }
21
+ seen.add(key);
22
+ if (values.has(key)) {
23
+ if (!tokens[i + 1] || tokens[i + 1].startsWith("--")) throw new Error(`tooling setup: --${key} requires a value.`);
24
+ i++;
25
+ }
26
+ }
27
+ if (typeof args.target !== "string" || !args.target.trim()) throw new Error("tooling setup: select the campaign folder with --target <directory>.");
28
+ if (args.platform && args.platform !== "claude") throw new Error("tooling setup: this entry supports --platform claude. Other agents can use install-skills and install-agent-context.");
29
+ }
30
+
31
+ function json(path) {
32
+ return JSON.parse(readFileSync(path, "utf8"));
33
+ }
34
+
35
+ function hasContextImport(text) {
36
+ let fence = null;
37
+ let found = false;
38
+ for (const line of text.split(/\r?\n/)) {
39
+ const marker = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
40
+ if (fence) {
41
+ if (marker && marker[1][0] === fence[0] && marker[1].length >= fence.length && !marker[2].trim()) fence = null;
42
+ } else if (marker) {
43
+ fence = marker[1];
44
+ } else if (/^ {0,3}@/.test(line) && line.trim() === IMPORT) {
45
+ found = true;
46
+ }
47
+ }
48
+ if (!found && fence) throw new Error("tooling setup: close the unterminated code fence in CLAUDE.md before setup can append an active context import; no files were changed.");
49
+ return found;
50
+ }
51
+
52
+ function regularDestination(root, path) {
53
+ const rel = relative(root, path);
54
+ if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
55
+ throw new Error(`tooling setup: destination must stay inside the selected project: ${path}.`);
56
+ }
57
+ const parts = rel.split(sep);
58
+ let current = root;
59
+ for (let i = 0; i < parts.length; i++) {
60
+ current = join(current, parts[i]);
61
+ let stat;
62
+ try { stat = lstatSync(current); } catch (error) {
63
+ // Every existing ancestor has already been checked. Once a component is
64
+ // absent, its descendants cannot exist; dangling symlinks still have lstat.
65
+ if (error.code === "ENOENT") return;
66
+ throw error;
67
+ }
68
+ if (stat.isSymbolicLink() || (i < parts.length - 1 ? !stat.isDirectory() : !stat.isFile())) {
69
+ throw new Error(`tooling setup: preserve ${current}; expected a regular ${i < parts.length - 1 ? "directory" : "file"}, not a symlink or another file type.`);
70
+ }
71
+ }
72
+ }
73
+
74
+ export function setupTooling(args, { packageRoot, installSkills, installAgentContext, installBrowser }) {
75
+ const target = realpathSync(resolve(args.target));
76
+ const pkg = json(join(packageRoot, "package.json"));
77
+ const manifestPath = join(target, "package.json");
78
+ const lockPath = join(target, "package-lock.json");
79
+ if (!existsSync(manifestPath)) {
80
+ throw new Error(`tooling setup: package.json is missing. For a new project, follow the pinned install in ${join(packageRoot, "docs/local-setup.md")}. For an existing project, restore its manifest and lockfile and run npm ci.`);
81
+ }
82
+ if (!existsSync(lockPath)) {
83
+ throw new Error("tooling setup: package-lock.json is missing. Restore the project's reviewed lockfile, or generate it from its existing dependency pins with npm install, then rerun setup. Do not replace the project's page-kit pin with a new-project example.");
84
+ }
85
+ const manifest = json(manifestPath);
86
+ const pins = [manifest.devDependencies?.[PACKAGE], manifest.dependencies?.[PACKAGE]].filter(Boolean);
87
+ if (!pins.length || pins.some((pin) => pin !== pkg.version)) {
88
+ throw new Error(`tooling setup: the project must pin ${PACKAGE} exactly to the running version ${pkg.version}; preserve its current pin or explicitly install the reviewed version first.`);
89
+ }
90
+ const installed = join(target, "node_modules", "@nextcommerce", "campaigns-os");
91
+ if (!existsSync(installed) || realpathSync(installed) !== realpathSync(packageRoot)) {
92
+ throw new Error("tooling setup: run the selected project's installed copy: cd into that folder and use npx --no-install campaigns-os tooling setup --target . --platform claude.");
93
+ }
94
+ const lock = json(lockPath);
95
+ if (lock.packages?.["node_modules/@nextcommerce/campaigns-os"]?.version !== pkg.version) {
96
+ throw new Error("tooling setup: the lockfile does not match the project toolkit pin; reconcile the reviewed dependency with npm before setup.");
97
+ }
98
+ if (!manifest.dependencies?.["next-campaign-page-kit"] && !manifest.devDependencies?.["next-campaign-page-kit"]) {
99
+ throw new Error("tooling setup: install next-campaign-page-kit in this project first. No campaign pages have been scaffolded or changed.");
100
+ }
101
+ if (!existsSync(join(target, "node_modules", "next-campaign-page-kit", "package.json"))) {
102
+ throw new Error("tooling setup: page-kit is declared but not installed; run npm ci in the selected project first.");
103
+ }
104
+
105
+ // Preflight every destination before any installer runs. Custom repository
106
+ // instructions are preserved; only one Claude import line is appended.
107
+ const instructions = join(target, "CLAUDE.md");
108
+ regularDestination(target, instructions);
109
+ regularDestination(target, join(target, ".gitignore"));
110
+ for (const name of ["CLAUDE.md", "AGENTS.md", "campaigns-os.mdc", "copilot-instructions.md"]) {
111
+ const dest = join(target, ".campaign-runtime", "agent-context", name);
112
+ regularDestination(target, dest);
113
+ const source = { "CLAUDE.md": "agents/claude/CLAUDE.md", "AGENTS.md": "agents/codex/AGENTS.md", "campaigns-os.mdc": "agents/cursor/campaigns-os.mdc", "copilot-instructions.md": "agents/copilot/copilot-instructions.md" }[name];
114
+ if (existsSync(dest) && readFileSync(dest, "utf8") !== readFileSync(join(packageRoot, source), "utf8")) {
115
+ throw new Error(`tooling setup: ${dest} differs from this toolkit's context. Preserve and reconcile it before rerunning setup; no files were changed.`);
116
+ }
117
+ }
118
+ const prior = existsSync(instructions) ? readFileSync(instructions, "utf8") : "";
119
+ const hasImport = hasContextImport(prior);
120
+ // Do the fallible download before changing the shared skills or project.
121
+ const browser = args["dry-run"] ? { ok: true, status: "not_run" } : installBrowser({ json: Boolean(args.json) });
122
+ const skills = browser.ok ? installSkills(null, Boolean(args["dry-run"]), "claude") : null;
123
+ const context = browser.ok ? installAgentContext(target, Boolean(args["dry-run"])) : null;
124
+ const contextFailed = context?.gitignore?.action === "skipped";
125
+ const ready = browser.ok && !contextFailed;
126
+ if (ready && !args["dry-run"] && !hasImport) {
127
+ writeFileSync(instructions, `${prior}${prior && !prior.endsWith("\n") ? "\n" : ""}\n${IMPORT}\n`);
128
+ }
129
+ const revision = json(join(packageRoot, "skills.json")).bundle_revision;
130
+ return {
131
+ ok: ready,
132
+ status: !browser.ok ? "browser_install_failed" : contextFailed ? "context_install_failed" : args["dry-run"] ? "dry_run" : "restart_required",
133
+ target_repo: target,
134
+ skills_revision: revision,
135
+ skills,
136
+ context,
137
+ instructions: { path: instructions, action: !ready ? "not_run" : hasImport ? "unchanged" : "append_import" },
138
+ browser,
139
+ next_action: contextFailed
140
+ ? `Setup could not add the runtime ignore block (${context.gitignore.reason}). Fix .gitignore and rerun setup; skills and context files may already be installed.`
141
+ : args["dry-run"]
142
+ ? "Run tooling setup with the same target and without --dry-run to install the browser, skills and agent context."
143
+ : browser.ok
144
+ ? `Restart Claude Code in this folder, then use the next-campaigns-os skill with your CampaignSpec and source material. Confirm the loaded bundle with npx --no-install campaigns-os tooling status --platform claude --skills-revision ${revision}.`
145
+ : "Fix the browser installation error and rerun tooling setup; existing page source and repository instructions are preserved.",
146
+ note: "Setup prepares tools; it does not scaffold pages, create a spec, select a campaign, log in, or prove that an agent loaded the installed skills.",
147
+ };
148
+ }
149
+
150
+ export function setupTextLines(result) {
151
+ return [
152
+ `Status: ${result.status.toUpperCase()}`,
153
+ `Campaign folder: ${result.target_repo}`,
154
+ `Skills revision: ${result.skills_revision}`,
155
+ `Browser: ${result.browser.status}`,
156
+ ...(result.browser.note ? [result.browser.note] : []),
157
+ result.next_action,
158
+ result.note,
159
+ ];
160
+ }