@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
package/src/deviation.mjs CHANGED
@@ -17,6 +17,17 @@ export const DEVIATION_JOURNAL_REL_PATH = ".campaign-runtime/agent-deviations.js
17
17
  // (doctor, next, findings, telemetry, run, validate-*) never deviate.
18
18
  export const TRACKED_STAGE_COMMANDS = Object.freeze(new Set(["start", "prepare-build", "theme", "polish", "qa", "run-record"]));
19
19
 
20
+ // Setup and metadata subcommands of a tracked command. They produce no stage
21
+ // output, so running one outside the recommendation is not a detour:
22
+ // qa install-browser — installs the package-owned QA browser binary;
23
+ // qa policy set — edits the packet's QA policy fields (and the
24
+ // assembly report's mirror of them);
25
+ // qa resolve — read-only diagnostic: prints the derived QA targets
26
+ // and, unless --no-probe, probes their reachability.
27
+ // Every other subcommand of a tracked command (`qa run`, `polish capture`,
28
+ // `theme generate`, ...) is still compared against the recommendation.
29
+ export const UNTRACKED_SUBCOMMANDS = Object.freeze(new Set(["qa install-browser", "qa policy", "qa resolve"]));
30
+
20
31
  // Commands every recommendation implicitly allows for its stage. Stage work is
21
32
  // agent/skill work, so the expected command set is small and explicit.
22
33
  const EXPECTED_COMMANDS_BY_STAGE = Object.freeze({
@@ -65,8 +76,9 @@ export function buildRecommendation({ stage, status, expectedCommands, now = new
65
76
  * Compare a pipeline-advancing command against the session's last
66
77
  * recommendation. Returns a deviation entry or null.
67
78
  */
68
- export function detectDeviation({ lastRecommendation, command, argvShape = [], runId = null, deviationReason = null, now = new Date() }) {
79
+ export function detectDeviation({ lastRecommendation, command, subcommand = null, argvShape = [], runId = null, deviationReason = null, now = new Date() }) {
69
80
  if (!TRACKED_STAGE_COMMANDS.has(command)) return null;
81
+ if (subcommand && UNTRACKED_SUBCOMMANDS.has(`${command} ${subcommand}`)) return null;
70
82
  if (!lastRecommendation || !Array.isArray(lastRecommendation.expected_commands)) return null;
71
83
  if (lastRecommendation.expected_commands.includes(command)) return null;
72
84
  return {
@@ -19,7 +19,11 @@ const REASONS = new Set([
19
19
  "theme_gate.starter_palette_only", "built_output.campaign_identity",
20
20
  "built_output.upsell_selector_scope", "built_output.sdk_markup.swap_with_add_to_cart",
21
21
  "built_output.sdk_markup.checkout_not_form", "built_output.sdk_markup.wrong_field_name",
22
- "built_output.sdk_markup.missing_selector_id_match",
22
+ "built_output.sdk_markup.missing_selector_id_match", "built_output.script_syntax.parse_failure",
23
+ "built_output.script_syntax.missing_script", "source_html.producer_provenance",
24
+ "source_html.producer_provenance.source_type", "source_html.producer_provenance.screenshot_fallback_used",
25
+ "source_html.producer_provenance.semantic_section_count", "source_html.producer_provenance.material_fingerprint",
26
+ "source_html.producer_provenance.section_exports", "source_html.producer_provenance.waiver_inert",
23
27
  ]);
24
28
  const ACTIONS = new Set([
25
29
  "repair_target", "align_store_profile", "align_sdk_version", "repair_waiver", "waive_checkpoint",
@@ -30,7 +34,7 @@ const ACTIONS = new Set([
30
34
  ]);
31
35
 
32
36
  const RECOVERY = Object.freeze({
33
- skills: { owner: "operator", action_id: "install-skills", input_needed: "Selected agent profile", instruction: "Refresh the bundled skills for that profile and restart the agent." },
37
+ skills: { owner: "operator", action_id: "install-skills", input_needed: "Selected agent profile", instruction: "Refresh the bundled skills for that profile, then read the SKILL.md files install-skills lists under Read now in the running session; restart the agent only if it cannot read them." },
34
38
  pin: { owner: "operator", action_id: "tooling.status", input_needed: "Reviewed toolkit version and lockfile", instruction: "Compare the installed package with the reviewed version; registry currency is not checked." },
35
39
  update: { owner: "operator", action_id: "tooling.status", input_needed: "Reviewed upstream revision", instruction: "Review and update the toolkit checkout using the existing worktree workflow." },
36
40
  doctor: { owner: "workflow_owner", action_id: "doctor", input_needed: "Local packet inputs and detailed doctor findings", instruction: "Inspect doctor locally and follow its existing owner, required inputs, and recovery actions." },
@@ -0,0 +1,270 @@
1
+ // A cross-process exclusive lock held as a directory.
2
+ //
3
+ // The lock directory and its owner record appear together: a holder builds
4
+ // `<lock>.staging-<token>/owner.json` beside the lock and renames the staging
5
+ // directory onto the lock path. The rename never replaces an existing entry,
6
+ // so of several processes exactly one publishes, and there is no moment at
7
+ // which the lock exists without the owner that holds it. Before entering its
8
+ // critical section the holder re-reads owner.json and requires its own token
9
+ // (fencing), and on release it only ever removes a directory that still
10
+ // carries its token.
11
+ //
12
+ // A lock left behind by a process that died is recovered: the owner's pid no
13
+ // longer exists, and one waiter claims recovery exclusively (the claim is
14
+ // published the same staged way) before renaming the abandoned lock away. An
15
+ // interrupted recovery claim fails closed rather than being stolen, which
16
+ // would reintroduce a check/rename race. A lock directory WITHOUT an owner
17
+ // record is never taken over: this module cannot produce one, so it belongs
18
+ // to an older writer that may still be alive between its mkdir and its owner
19
+ // write (#501). A waiter refuses it after a short grace, leaving it for the
20
+ // documented offline procedure. (See publishStagedDirectory for the one
21
+ // mixed-version race this cannot close, tracked in #514.)
22
+ //
23
+ // The lock is reentrant for its holder: code running inside `fn` (in the
24
+ // same async context) that asks for the same lock enters directly instead of
25
+ // waiting on itself. prepare-build holds the per-target lock and can reach
26
+ // stage writers that take it too.
27
+ import { AsyncLocalStorage } from "node:async_hooks";
28
+ import { randomBytes } from "node:crypto";
29
+ import { lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
30
+ import { basename, dirname, join, resolve } from "node:path";
31
+
32
+ const heldLocks = new AsyncLocalStorage();
33
+
34
+ const readOwner = (path) => {
35
+ try { return JSON.parse(readFileSync(path, "utf8")); } catch { return null; }
36
+ };
37
+
38
+ const exists = (path) => {
39
+ try { lstatSync(path); return true; } catch { return false; }
40
+ };
41
+
42
+ // The reentrancy key names the lock directory through its real parent, so a
43
+ // holder that reached the target through a symlink still recognizes itself.
44
+ function lockKey(path) {
45
+ const absolute = resolve(path);
46
+ try { return join(realpathSync(dirname(absolute)), basename(absolute)); } catch { return absolute; }
47
+ }
48
+
49
+ function stageOwnedDirectory(stagingPath, owner) {
50
+ rmSync(stagingPath, { recursive: true, force: true });
51
+ mkdirSync(stagingPath);
52
+ try {
53
+ writeFileSync(join(stagingPath, "owner.json"), `${JSON.stringify(owner)}\n`, { mode: 0o600 });
54
+ } catch (error) {
55
+ rmSync(stagingPath, { recursive: true, force: true });
56
+ throw error;
57
+ }
58
+ }
59
+
60
+ // rename(2) onto a non-empty directory fails with one of these; the holder
61
+ // may already have released by the time the error is seen, so they are
62
+ // contention whether or not the destination still exists.
63
+ const CONTENTION_CODES = new Set(["EEXIST", "ENOTEMPTY"]);
64
+ // Codes some platforms use for an existing destination (EPERM on Windows)
65
+ // that are also genuine failures: contention only while the destination
66
+ // exists.
67
+ const MAYBE_CONTENTION_CODES = new Set(["EPERM", "ENOTDIR", "EISDIR"]);
68
+
69
+ // Returns false when `dest` is held by someone else; throws on any other
70
+ // failure. The staging directory is gone either way.
71
+ function publishStagedDirectory(stagingPath, dest) {
72
+ try {
73
+ // rename(2) replaces an EMPTY destination directory, so never rename over
74
+ // an existing entry. This module never leaves an empty directory at a
75
+ // lock path; only an older release does, for the instant between its
76
+ // mkdir and its owner write. A new writer's check-then-rename can land in
77
+ // that instant and replace it, so running an older release and this one
78
+ // on the same target at the same moment is not safe (#501; tracked in #514).
79
+ if (exists(dest)) return false;
80
+ try {
81
+ renameSync(stagingPath, dest);
82
+ } catch (error) {
83
+ if (CONTENTION_CODES.has(error?.code)) return false;
84
+ if (MAYBE_CONTENTION_CODES.has(error?.code) && exists(dest)) return false;
85
+ throw error;
86
+ }
87
+ return true;
88
+ } finally {
89
+ rmSync(stagingPath, { recursive: true, force: true });
90
+ }
91
+ }
92
+
93
+ // An ownerless lock is never taken over, so waiting out the whole budget on
94
+ // one only delays the refusal. A live older writer fills its owner within
95
+ // microseconds; one still ownerless after this grace is refused at once.
96
+ const OWNERLESS_GRACE_MS = 1000;
97
+
98
+ function createLock(path, { budgetMs, unavailable, now = Date.now, ownerlessGraceMs = OWNERLESS_GRACE_MS }) {
99
+ const token = randomBytes(16).toString("hex");
100
+ const start = now();
101
+ const owner = { pid: process.pid, token };
102
+ const ownerPath = join(path, "owner.json");
103
+ const stagingPath = `${path}.staging-${token}`;
104
+
105
+ const abandoned = () => {
106
+ try {
107
+ const stat = lstatSync(path);
108
+ if (!stat.isDirectory() || stat.isSymbolicLink()) return false;
109
+ const current = readOwner(ownerPath);
110
+ if (!(Number.isInteger(current?.pid) && current.pid > 0 && typeof current.token === "string")) return false;
111
+ try { process.kill(current.pid, 0); return false; } catch (error) { return error.code === "ESRCH"; }
112
+ } catch {
113
+ return false;
114
+ }
115
+ };
116
+
117
+ const recover = () => {
118
+ if (!abandoned()) return;
119
+ const deadToken = readOwner(ownerPath)?.token;
120
+ const claim = join(path, ".recovery");
121
+ try {
122
+ const claimStaging = `${path}.recovery-staging-${token}`;
123
+ stageOwnedDirectory(claimStaging, owner);
124
+ if (!publishStagedDirectory(claimStaging, claim)) return;
125
+ } catch {
126
+ return;
127
+ }
128
+ let moved = false;
129
+ try {
130
+ // Re-check under the claim: the owner must still be the same dead one.
131
+ if (!abandoned() || readOwner(ownerPath)?.token !== deadToken) return;
132
+ const tomb = `${path}.abandoned-${token}`;
133
+ renameSync(path, tomb);
134
+ moved = true;
135
+ rmSync(tomb, { recursive: true, force: true });
136
+ } catch {
137
+ // Leave the lock for the next waiter or the offline procedure.
138
+ } finally {
139
+ if (!moved && readOwner(join(claim, "owner.json"))?.token === token) {
140
+ try { rmSync(claim, { recursive: true, force: true }); } catch {}
141
+ }
142
+ }
143
+ };
144
+
145
+ const stage = () => stageOwnedDirectory(stagingPath, owner);
146
+ // Publish, then fence on the token actually on disk.
147
+ const publish = () => publishStagedDirectory(stagingPath, path) && readOwner(ownerPath)?.token === token;
148
+ const heldBySelfProcess = () => readOwner(ownerPath)?.pid === process.pid;
149
+ // The identity of the lock directory if it is ownerless, else null. Two
150
+ // stats cannot see the lock atomically: a holder can release between the
151
+ // stat of the directory and the stat of its owner, which reads as a missing
152
+ // owner. So the directory must still be the same one after the owner was
153
+ // found missing, and the grace runs only while the same directory stays
154
+ // ownerless: holders coming and going never add up to one ownerless lock.
155
+ let ownerlessSince = null;
156
+ let ownerlessIdentity = null;
157
+ const ownerless = () => {
158
+ try {
159
+ const before = lstatSync(path);
160
+ if (!before.isDirectory() || exists(ownerPath)) return null;
161
+ const after = lstatSync(path);
162
+ if (after.dev !== before.dev || after.ino !== before.ino || after.birthtimeMs !== before.birthtimeMs) return null;
163
+ return `${before.dev}:${before.ino}:${before.birthtimeMs}`;
164
+ } catch {
165
+ return null;
166
+ }
167
+ };
168
+ // True once the budget is spent, or once one lock directory has stayed
169
+ // ownerless past the grace period.
170
+ const expired = () => {
171
+ const current = now();
172
+ const identity = ownerless();
173
+ if (identity && identity === ownerlessIdentity) {
174
+ if (current - ownerlessSince >= ownerlessGraceMs) return true;
175
+ } else {
176
+ ownerlessIdentity = identity;
177
+ ownerlessSince = identity ? current : null;
178
+ }
179
+ return current - start >= budgetMs;
180
+ };
181
+ const fail = (error) => unavailable(error);
182
+ const contended = () => Object.assign(new Error(`Lock is held: ${path}`), { code: "EEXIST" });
183
+
184
+ const release = () => {
185
+ if (readOwner(ownerPath)?.token !== token) return;
186
+ const tomb = `${path}.released-${token}`;
187
+ try { renameSync(path, tomb); } catch { return; }
188
+ if (readOwner(join(tomb, "owner.json"))?.token === token) {
189
+ rmSync(tomb, { recursive: true, force: true });
190
+ } else {
191
+ // Not ours after all: put it back rather than delete another holder's lock.
192
+ try { renameSync(tomb, path); } catch {}
193
+ }
194
+ };
195
+
196
+ return { token, stage, publish, recover, heldBySelfProcess, expired, fail, contended, release };
197
+ }
198
+
199
+ function reentrantKey(path) {
200
+ const key = lockKey(path);
201
+ const token = heldLocks.getStore()?.get(key);
202
+ const held = Boolean(token) && readOwner(join(path, "owner.json"))?.token === token;
203
+ return { key, held };
204
+ }
205
+
206
+ function runHolding(key, token, fn) {
207
+ const held = new Map(heldLocks.getStore() ?? []);
208
+ held.set(key, token);
209
+ return heldLocks.run(held, fn);
210
+ }
211
+
212
+ // Options: budgetMs, unavailable(error) -> Error; test seams: now() for the
213
+ // budget clock, sleep(ms) between attempts, hooks.beforePublish() awaited
214
+ // between staging the owner and publishing the lock.
215
+ export async function withDirectoryLock(path, fn, options) {
216
+ const { key, held } = reentrantKey(path);
217
+ if (held) return fn();
218
+ const lock = createLock(path, options);
219
+ const sleep = options.sleep ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
220
+ while (true) {
221
+ let acquired;
222
+ try {
223
+ lock.stage();
224
+ if (options.hooks?.beforePublish) await options.hooks.beforePublish();
225
+ acquired = lock.publish();
226
+ } catch (error) {
227
+ throw lock.fail(error);
228
+ }
229
+ if (acquired) break;
230
+ if (lock.expired()) throw lock.fail(lock.contended());
231
+ lock.recover();
232
+ await sleep(20);
233
+ }
234
+ try {
235
+ return await runHolding(key, lock.token, fn);
236
+ } finally {
237
+ lock.release();
238
+ }
239
+ }
240
+
241
+ const sleepSync = (ms) => { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); };
242
+
243
+ // The synchronous form, for writers whose callers are synchronous. It blocks
244
+ // the event loop while it waits, so when the lock is held by this same
245
+ // process outside the caller's async context it refuses at once instead of
246
+ // waiting out its budget: that holder cannot run until this call returns.
247
+ export function withDirectoryLockSync(path, fn, options) {
248
+ const { key, held } = reentrantKey(path);
249
+ if (held) return fn();
250
+ const lock = createLock(path, options);
251
+ while (true) {
252
+ let acquired;
253
+ try {
254
+ lock.stage();
255
+ options.hooks?.beforePublish?.();
256
+ acquired = lock.publish();
257
+ } catch (error) {
258
+ throw lock.fail(error);
259
+ }
260
+ if (acquired) break;
261
+ if (lock.expired() || lock.heldBySelfProcess()) throw lock.fail(lock.contended());
262
+ lock.recover();
263
+ (options.sleep ?? sleepSync)(20);
264
+ }
265
+ try {
266
+ return runHolding(key, lock.token, fn);
267
+ } finally {
268
+ lock.release();
269
+ }
270
+ }