@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
@@ -20,10 +20,12 @@ import { existsSync, readFileSync } from "node:fs";
20
20
  import { basename, dirname, join, resolve } from "node:path";
21
21
 
22
22
  import { campaignSidecarPaths, targetRepoFor } from "./campaign-workspace.mjs";
23
+ import { refused } from "./lifecycle.mjs";
23
24
  import { SIDECAR_RELATIVE_PATH } from "./qa-sidecar.mjs";
24
25
  import { qaVerdictIdentityMatch } from "./qa-verdict-discovery.mjs";
25
- import { publishQaVerdict, qaPortalUrl, qaVerdictPublishBlock, QA_VERDICT_PUBLISHERS } from "./qa-verdict-publish.mjs";
26
+ import { publishQaVerdict, qaPortalUrl, qaVerdictPublishBlock, QA_VERDICT_PUBLISH_ENDPOINT, QA_VERDICT_PUBLISHERS } from "./qa-verdict-publish.mjs";
26
27
  import { validateVerdict } from "./qa-verdict.mjs";
28
+ import { assertFetchAvailable, assertSecureProxyBase, classifyRemitOutcome, describeRemitBaseKind } from "./remit.mjs";
27
29
  import { readRunRecordsForTarget, writeRunRecord } from "./run-record.mjs";
28
30
  import { identityMatches } from "./run-record-closeout.mjs";
29
31
  import { DEFAULT_PROXY_BASE } from "./spec-fetch.mjs";
@@ -32,6 +34,9 @@ import { singleLineFragment } from "./text-safety.mjs";
32
34
 
33
35
  export const QA_PUBLISH_STATUSES = Object.freeze({
34
36
  published: "published",
37
+ // --dry-run: every refusal check ran and none fired, so a real run would
38
+ // have posted. Nothing was sent, so this is neither published nor failed.
39
+ dry_run: "dry_run",
35
40
  refused: "refused",
36
41
  publish_failed: "publish_failed",
37
42
  });
@@ -41,6 +46,7 @@ export const QA_PUBLISH_STATUSES = Object.freeze({
41
46
  // thrown error, so --json readers get a code to branch on.
42
47
  export const QA_PUBLISH_REFUSALS = Object.freeze({
43
48
  packet_required: "packet_required",
49
+ local_spec: "local_spec",
44
50
  order_flags_refused: "order_flags_refused",
45
51
  verdict_missing: "verdict_missing",
46
52
  verdict_unreadable: "verdict_unreadable",
@@ -55,6 +61,7 @@ export const QA_PUBLISH_REFUSALS = Object.freeze({
55
61
 
56
62
  export const QA_PUBLISH_EXIT_CODES = Object.freeze({
57
63
  [QA_PUBLISH_STATUSES.published]: 0,
64
+ [QA_PUBLISH_STATUSES.dry_run]: 0,
58
65
  [QA_PUBLISH_STATUSES.refused]: 2,
59
66
  [QA_PUBLISH_STATUSES.publish_failed]: 1,
60
67
  });
@@ -165,6 +172,25 @@ export function findRunRecordForVerdict({ records, packet, verdictRunId, verdict
165
172
  * the record stamping are assertable without a receiver or a filesystem.
166
173
  */
167
174
  export async function publishStoredVerdict(args, operations = {}) {
175
+ // `--dry-run` is a bare flag; `--dry-run true` must fail rather than quietly
176
+ // become a real POST.
177
+ // An up-front flag refusal, tagged like every other so the lifecycle
178
+ // journal records nothing for it (a plain Error here would be journaled as
179
+ // a handler failure).
180
+ if (Object.hasOwn(args, "dry-run") && args["dry-run"] !== true) {
181
+ throw refused(`--dry-run takes no value (got ${JSON.stringify(args["dry-run"])}); write \`--dry-run\` on its own, after the other flags.`);
182
+ }
183
+ const dryRun = args["dry-run"] === true;
184
+ const result = await attemptPublish(args, operations, dryRun);
185
+ if (!dryRun) return result;
186
+ // The envelope says what the real command would have done. A refusal is a
187
+ // refusal either way — same code, same exit — and so is a destination the
188
+ // transport gate turns down before any request; `would_publish` is true only
189
+ // when every check passed and the post is all that is left.
190
+ return { ...result, dry_run: true, would_publish: result.status === QA_PUBLISH_STATUSES.dry_run };
191
+ }
192
+
193
+ async function attemptPublish(args, operations, dryRun) {
168
194
  const ops = {
169
195
  readJsonFile: readJson,
170
196
  exists: existsSync,
@@ -193,6 +219,9 @@ export async function publishStoredVerdict(args, operations = {}) {
193
219
  return refusal(QA_PUBLISH_REFUSALS.packet_required, `Build Packet ${packetPath} is not readable (${error.code || error.message}).`);
194
220
  }
195
221
 
222
+ if (packet?.spec?.local_spec_id != null) {
223
+ return refusal(QA_PUBLISH_REFUSALS.local_spec, "Local-spec QA has no saved Map destination; keep its verdict in the repository. Portal publication requires a saved Map and fresh evidence.");
224
+ }
196
225
  const source = resolveStoredVerdictSource({ args, packetPath, packet, readJsonFile: ops.readJsonFile, exists: ops.exists });
197
226
  if (source.error) return source.error;
198
227
  let verdict;
@@ -268,6 +297,70 @@ export async function publishStoredVerdict(args, operations = {}) {
268
297
  );
269
298
  }
270
299
 
300
+ // Every refusal check above has run and none fired, so a real run would post
301
+ // now. Under --dry-run this is where it stops: nothing is sent, and the Run
302
+ // Record below is left exactly as it stands.
303
+ if (dryRun) {
304
+ // First, the checks the transport itself makes before it opens a socket.
305
+ // `publishQaVerdict` -> `remit` demands a fetch to send with
306
+ // (`assertFetchAvailable`) and then puts every destination through
307
+ // `assertSecureProxyBase`; a runtime with no global fetch, or a base that
308
+ // is not https (nor a loopback host for local testing), fails there,
309
+ // locally, with nothing sent: the real command reports publish_failed and
310
+ // exits 1. A preview may not approve a send the transport refuses, so both
311
+ // gates run here, in the transport's own order — the same functions, with
312
+ // the label and the (absent) credential the publish rail passes them — and
313
+ // the refusal is classified by the same classifier the real outcome goes
314
+ // through, so the message and the exit code are the real command's.
315
+ // `attempted: false` is the one difference: nothing was sent.
316
+ let destinationRefusal = null;
317
+ try {
318
+ assertFetchAvailable(globalThis.fetch);
319
+ assertSecureProxyBase(proxyBase, { label: "QA verdict publish", credential: null });
320
+ } catch (error) {
321
+ destinationRefusal = classifyRemitOutcome(error);
322
+ }
323
+ if (destinationRefusal) {
324
+ return {
325
+ ok: false,
326
+ action: "qa-publish",
327
+ status: QA_PUBLISH_STATUSES.publish_failed,
328
+ ...identity,
329
+ republished: republish && priorBlock?.state === "ok",
330
+ publish: {
331
+ attempted: false,
332
+ ok: destinationRefusal.ok,
333
+ error: destinationRefusal.error,
334
+ endpoint: QA_VERDICT_PUBLISH_ENDPOINT,
335
+ result: destinationRefusal.result,
336
+ http_status: destinationRefusal.http_status,
337
+ base_kind: describeRemitBaseKind(proxyBase),
338
+ published_at: null,
339
+ },
340
+ dashboard_url: null,
341
+ run_record: recordEntry ? { ...recordSummary(recordEntry, priorBlock), written: false, preserved: false } : null,
342
+ orders_placed: 0,
343
+ };
344
+ }
345
+ return {
346
+ ok: true,
347
+ action: "qa-publish",
348
+ status: QA_PUBLISH_STATUSES.dry_run,
349
+ ...identity,
350
+ republished: republish && priorBlock?.state === "ok",
351
+ would_post: {
352
+ endpoint: QA_VERDICT_PUBLISH_ENDPOINT,
353
+ base_kind: describeRemitBaseKind(proxyBase),
354
+ verdict_run_id: verdictRunId,
355
+ payload_bytes: Buffer.byteLength(JSON.stringify(verdict), "utf8"),
356
+ },
357
+ publish: { attempted: false, ok: null, error: null, endpoint: QA_VERDICT_PUBLISH_ENDPOINT, result: null, http_status: null, base_kind: describeRemitBaseKind(proxyBase), published_at: null },
358
+ dashboard_url: qaPortalUrl(proxyBase, mapId, verdictRunId),
359
+ run_record: recordEntry ? { ...recordSummary(recordEntry, priorBlock), written: false, preserved: false } : null,
360
+ orders_placed: 0,
361
+ };
362
+ }
363
+
271
364
  const outcome = await ops.post(verdict, proxyBase);
272
365
  const publishedAt = ops.now();
273
366
  const block = qaVerdictPublishBlock(outcome, { verdictRunId, publisher: QA_VERDICT_PUBLISHERS.publish, publishedAt });
@@ -332,6 +425,21 @@ export function qaPublishTextLines(result, { cmd = (verb) => `campaigns-os ${ver
332
425
  if (result.verdict_path) lines.push(`Verdict: ${result.verdict_path}${result.source_kind ? ` (${result.source_kind})` : ""}`);
333
426
  if (result.dashboard_url) lines.push(`QA portal: ${result.dashboard_url}`);
334
427
  lines.push("No order was placed and nothing was sent.");
428
+ if (result.dry_run) lines.push("Dry run (--dry-run): the real command refuses this the same way.");
429
+ return lines;
430
+ }
431
+ if (result.status === QA_PUBLISH_STATUSES.dry_run) {
432
+ const post = result.would_post || {};
433
+ lines.push("QA publish dry run (--dry-run): every refusal check passed and nothing was sent.");
434
+ lines.push(`Map ID: ${result.map_id}`);
435
+ lines.push(`Run ID: ${result.run_id}`);
436
+ lines.push(`Disposition: ${result.disposition}`);
437
+ lines.push(`Verdict: ${result.verdict_path} (${result.source_kind})`);
438
+ lines.push(`Would POST: ${post.endpoint} at ${result.proxy_base} [base: ${post.base_kind}] — verdict ${post.verdict_run_id}, ${post.payload_bytes} bytes`);
439
+ lines.push(result.run_record
440
+ ? `Run Record: ${result.run_record.run_id} would take the publish outcome (${result.run_record.path}); it was not touched.`
441
+ : "Run Record: none references this verdict under the packet's campaign, so a real publish would not be recorded on one.");
442
+ lines.push("Orders placed: 0 (qa publish never places orders).");
335
443
  return lines;
336
444
  }
337
445
  lines.push(result.status === QA_PUBLISH_STATUSES.published
@@ -356,7 +464,9 @@ export function qaPublishTextLines(result, { cmd = (verb) => `campaigns-os ${ver
356
464
  }
357
465
  lines.push("Orders placed: 0 (qa publish never places orders).");
358
466
  if (result.status === QA_PUBLISH_STATUSES.publish_failed) {
359
- lines.push(`Re-run ${cmd("qa")} publish with network access; the local verdict is untouched.`);
467
+ lines.push(result.dry_run
468
+ ? `Dry run (--dry-run): nothing was sent, and the real command fails here the same way — the destination is refused before any request. ${result.publish?.error || ""}`.trimEnd()
469
+ : `Re-run ${cmd("qa")} publish with network access; the local verdict is untouched.`);
360
470
  }
361
471
  return lines;
362
472
  }
@@ -1,3 +1,4 @@
1
+ import { localSpecIdentityFields } from "./spec-source-identity.mjs";
1
2
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
3
  import { randomUUID } from "node:crypto";
3
4
  import { dirname, join, resolve } from "node:path";
@@ -77,6 +78,7 @@ export function projectVerdictForSidecar(verdict, { generatedAt }) {
77
78
  schema_version: verdict.schema_version,
78
79
  run_id: verdict.run_id,
79
80
  campaign_slug: verdict.campaign_slug,
81
+ ...localSpecIdentityFields(verdict),
80
82
  ...(verdict.public_route_slug != null ? { public_route_slug: verdict.public_route_slug } : {}),
81
83
  ...(verdict.campaign_ref_id != null ? { campaign_ref_id: verdict.campaign_ref_id } : {}),
82
84
  spec_version: verdict.spec_version,
@@ -1,3 +1,4 @@
1
+ import { resolveCampaignIdentity, localQaIdentifier } from "./spec-source-identity.mjs";
1
2
  // QA verdict discovery: the one walk over the local verdicts a campaign has
2
3
  // written, with the projections each reader needs.
3
4
  //
@@ -28,11 +29,20 @@ function qaVerdictSlug(packet) {
28
29
  // the slug as identity. A verdict is this campaign's when its campaign_slug
29
30
  // is one of them.
30
31
  export function qaVerdictIdentifiers(packet) {
32
+ if (packet?.spec?.local_spec_id != null) {
33
+ const identity = resolveCampaignIdentity(packet.spec);
34
+ return identity?.kind === "local_spec" ? [localQaIdentifier(identity.id)] : [];
35
+ }
31
36
  return [...new Set([optionalString(packet?.spec?.map_id), qaVerdictSlug(packet)].filter(Boolean))];
32
37
  }
33
38
 
34
39
  export function qaVerdictIdentityMatch(verdict, packet) {
35
40
  const slug = optionalString(verdict?.campaign_slug);
41
+ if (packet?.spec?.local_spec_id != null || verdict?.local_spec_id != null) {
42
+ const identity = resolveCampaignIdentity(packet?.spec);
43
+ return identity?.kind === "local_spec" && verdict?.local_spec_id === identity.id
44
+ && slug === localQaIdentifier(identity.id);
45
+ }
36
46
  return Boolean(slug) && qaVerdictIdentifiers(packet).includes(slug);
37
47
  }
38
48
 
@@ -174,6 +184,7 @@ export function qaVerdictCandidateScore(candidate, packet) {
174
184
  const mapId = optionalString(packet?.spec?.map_id);
175
185
  const slug = qaVerdictSlug(packet);
176
186
  let score = 0;
187
+ if (packet?.spec?.local_spec_id && qaVerdictIdentityMatch(verdict, packet)) score += 100;
177
188
  if (mapId && verdict.campaign_slug === mapId) score += 100;
178
189
  if (slug && verdict.campaign_slug === slug) score += 80;
179
190
  if (verdict.schema_version === "1.0" || verdict.schema_version === "campaigns-os-qa-verdict/v0") score += 10;
@@ -42,6 +42,7 @@ export const QA_VERDICT_PUBLISH_STATES = Object.freeze(["skipped", "ok", "failed
42
42
  * must not claim a credential is travelling in clear.
43
43
  */
44
44
  export async function publishQaVerdict(verdict, proxyBase, { fetchImpl = globalThis.fetch, remitImpl = remit } = {}) {
45
+ if (verdict?.local_spec_id != null) return { ...skippedQaVerdictPublish(), reason: "local_spec" };
45
46
  let httpStatus = null;
46
47
  let response = null;
47
48
  let failure = null;
@@ -1,3 +1,4 @@
1
+ import { resolveCampaignIdentity, localQaIdentifier } from "./spec-source-identity.mjs";
1
2
  export const QA_SCHEMA_VERSION = "1.0";
2
3
 
3
4
  export const STATUS = Object.freeze({
@@ -95,6 +96,7 @@ function dispositionWithCommercial(assertions, commercial) {
95
96
  export function createVerdict({
96
97
  runId,
97
98
  mapId,
99
+ localSpecId = null,
98
100
  publicRouteSlug = null,
99
101
  campaignRefId = null,
100
102
  specVersion,
@@ -126,7 +128,8 @@ export function createVerdict({
126
128
  run_id: runId,
127
129
  // campaign_slug carries the Map ID for schema back-compat; the true public
128
130
  // route slug rides alongside so consumers stop conflating the two.
129
- campaign_slug: mapId,
131
+ campaign_slug: localSpecId ? localQaIdentifier(localSpecId) : mapId,
132
+ ...(localSpecId ? { local_spec_id: localSpecId } : {}),
130
133
  public_route_slug: optionalString(publicRouteSlug),
131
134
  campaign_ref_id: campaignRefId,
132
135
  spec_version: specVersion,
@@ -208,6 +211,10 @@ export function deriveExceptions(assertions = []) {
208
211
 
209
212
  export function validateVerdict(verdict) {
210
213
  const errors = [];
214
+ if (verdict?.local_spec_id != null && (!resolveCampaignIdentity({ local_spec_id: verdict.local_spec_id })
215
+ || verdict.campaign_slug !== localQaIdentifier(verdict.local_spec_id))) {
216
+ errors.push("Local-spec verdict requires a valid local_spec_id and its local QA identifier.");
217
+ }
211
218
  if (!verdict || typeof verdict !== "object" || Array.isArray(verdict)) {
212
219
  return ["verdict: must be an object"];
213
220
  }