@nextcommerce/campaigns-os 1.41.2 → 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 (56) hide show
  1. package/CHANGELOG.md +178 -0
  2. package/README.md +6 -4
  3. package/agents/claude/CLAUDE.md +5 -1
  4. package/campaign-spec/dist/types.d.ts +2 -0
  5. package/contracts/effects.v1.json +111 -18
  6. package/contracts/migration-sidecar-bundle.v0.json +9 -0
  7. package/contracts/release-ledger.json +752 -0
  8. package/contracts/supported-surface.json +12 -11
  9. package/docs/build-packet.md +56 -6
  10. package/docs/campaigns-os-build-flow.md +2 -2
  11. package/docs/effects.md +70 -1
  12. package/docs/local-setup.md +51 -0
  13. package/docs/migration-sidecar-bundle.md +6 -1
  14. package/docs/orientation-contract-reference.md +1 -1
  15. package/docs/progress-snapshots.md +6 -0
  16. package/docs/qa-and-test-orders.md +26 -10
  17. package/docs/runtime-readiness.md +1 -1
  18. package/docs/skills-revision.md +10 -10
  19. package/package.json +3 -2
  20. package/schemas/campaign-runtime-assembly-report.v0.schema.json +6 -1
  21. package/schemas/campaign-runtime-build-packet.v0.schema.json +6 -1
  22. package/schemas/campaign-spec.v4.schema.json +4 -0
  23. package/schemas/campaigns-os-progress-snapshot.v0.schema.json +1 -0
  24. package/schemas/campaigns-os-qa-verdict-sidecar.v0.schema.json +1 -0
  25. package/schemas/campaigns-os-qa-verdict.v0.schema.json +1 -0
  26. package/schemas/campaigns-os-run-record.v0.schema.json +1 -0
  27. package/skills/campaign-lifecycle-orientation/SKILL.md +13 -8
  28. package/skills/campaign-readback-classification/SKILL.md +3 -3
  29. package/skills/campaign-run-evidence/SKILL.md +8 -6
  30. package/skills/contribution-intake/SKILL.md +3 -3
  31. package/skills/next-campaigns-build/SKILL.md +3 -3
  32. package/skills/next-campaigns-os/SKILL.md +17 -4
  33. package/skills/next-campaigns-os/references/session-intake.md +4 -4
  34. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  35. package/skills/next-campaigns-polish/SKILL.md +3 -3
  36. package/skills/next-campaigns-qa/SKILL.md +10 -9
  37. package/skills.json +11 -11
  38. package/src/cli.mjs +187 -70
  39. package/src/finding-cause.mjs +14 -10
  40. package/src/lifecycle.mjs +5 -4
  41. package/src/polish-node.mjs +5 -2
  42. package/src/progress-node.mjs +3 -2
  43. package/src/progress.mjs +5 -3
  44. package/src/qa-node.mjs +58 -26
  45. package/src/qa-publish.mjs +4 -0
  46. package/src/qa-sidecar.mjs +2 -0
  47. package/src/qa-verdict-discovery.mjs +11 -0
  48. package/src/qa-verdict-publish.mjs +1 -0
  49. package/src/qa-verdict.mjs +8 -1
  50. package/src/readback.mjs +2 -1
  51. package/src/run-record-closeout.mjs +3 -4
  52. package/src/run-record.mjs +4 -0
  53. package/src/sidecar-bundle.mjs +21 -0
  54. package/src/spec-source-identity.mjs +44 -0
  55. package/src/stage-ledger.mjs +4 -1
  56. package/src/tooling-setup.mjs +160 -0
package/src/cli.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { campaignSpecIdentity, resolveCampaignIdentity, campaignIdentitiesMatch, localSpecIdentityFields } from "./spec-source-identity.mjs";
1
2
  import { withHtmlScanSnapshot, readHtmlScanText, htmlScanDigest } from "./html-scan.mjs";
2
3
  import { createHash, randomUUID } from "node:crypto";
3
4
  import { createDemo, demoArguments } from "./demo.mjs";
@@ -28,7 +29,7 @@ import { observeProgress, PROGRESS_OBSERVATION } from "./progress-node.mjs";
28
29
  import { describeSdkIgnoredMetaTags, isSdkIgnoredMetaTag } from "./sdk-meta-tags.mjs";
29
30
  import { HIDDEN_EAGER_MEDIA_ACTIONS, requiredActionText, substitutePacket } from "./gate-actions.mjs";
30
31
  import { ORDER_PATH_DEPTH_DRIFT_CODE, orderPathDepthDriftText, orderPathDepthReconcileAction, orderPathDepthsDisagree, parseOrderPathDepthFlag } from "./proof-policy.mjs";
31
- import { specMaterialHash } from "./spec-identity.mjs";
32
+ import { specMaterialHash, specHashesMatch } from "./spec-identity.mjs";
32
33
  // The same predicate stage-ledger.mjs judges a mutator's result with, imported
33
34
  // rather than re-stated so the waive preview and the commit agree by identity.
34
35
  import { isPlainObject } from "./repo-scan.mjs";
@@ -98,7 +99,7 @@ import { campaignSidecarPaths, resolveCampaignWorkspace, targetRepoFor } from ".
98
99
  import { canonicalPath, sameFile } from "./fs-identity.mjs";
99
100
  import { DEFAULT_PROXY_BASE, fetchSpecByMapId } from "./spec-fetch.mjs";
100
101
  import { writeMapSdkPin } from "./map-pin-writeback.mjs";
101
- import { discoverQaVerdicts, iterateQaVerdicts, qaVerdictCandidateScore, qaVerdictCandidateTime, qaVerdictPathHints } from "./qa-verdict-discovery.mjs";
102
+ import { qaVerdictIdentityMatch, discoverQaVerdicts, iterateQaVerdicts, qaVerdictCandidateScore, qaVerdictCandidateTime, qaVerdictPathHints } from "./qa-verdict-discovery.mjs";
102
103
  import { assertFetchAvailable, assertSecureProxyBase, boundedResponseText, DEFAULT_RUNS_ENDPOINT, describeRemitBaseKind, isLoopbackHostname, REMIT_RESULTS, remitRunRecord } from "./remit.mjs";
103
104
  import {
104
105
  aggregateLifecycleForRun,
@@ -485,12 +486,13 @@ Usage:
485
486
  campaigns-os readback --example [--json] # project the bundled synthetic sample; freshness is not computable for it by design
486
487
  campaigns-os validate-assembly-report --report <json> [--json]
487
488
  campaigns-os install-skills [--platform <claude|codex|agents|all>] [--target <skills-dir>] [--dry-run] [--json]
489
+ campaigns-os tooling setup --target <campaign-directory> [--platform claude] [--dry-run] [--json] # after installing the pinned project dependencies, install skills, connect Claude's context and install the QA browser; preserve existing pages and instructions, then restart the agent
488
490
  campaigns-os login [--store <subdomain>]
489
491
  campaigns-os logout [--store <subdomain>]
490
492
  campaigns-os tooling status [--platform <claude|codex|agents|all>] [--target <skills-dir>] [--skills-revision <bundle-revision|skill-id@version>] [--packet <campaign-runtime.build.json>] [--force] [--json] # install-mode, git, skill freshness, and local gateway login/store/expiry/reported version. --skills-revision checks the bundle revision the skill you loaded states on its first body line (or that one skill's <skill-id>@<version>) against the bundle THIS CLI ships: revision_check is match, mismatch or unchecked, and a mismatch prints the full status and exits 2 because skill text already in context cannot be refreshed by re-running — start a fresh session. The pin check reports one executable per project: the project pin first — the first exact spec for this package (x.y.z, =x.y.z or vx.y.z) on the walk up from the nearest package.json, devDependencies then dependencies in each, entering a workspace root and stopping there, never peerDependencies or optionalDependencies — then the Build Packet's campaigns_os_version (the project's campaign-runtime.build.json, or --packet <path>); the Pin: line names the key and manifest (or packet) each version came from; pin.status is match, stale_pin (the pin is not the running version), conflicting_pin (the two sources disagree) or unpinned (neither, or only a range; exit 0). stale_pin and conflicting_pin exit 2 with the file to change; --force (bare) overrides them, is reported as pin.forced and lands on the lifecycle journal entry. See docs/skills-revision.md
491
493
  campaigns-os tooling diagnose [--packet <packet>] [--platform <claude|codex|agents|all>] [--json] # read-only redacted support summary
492
494
  campaigns-os install-agent-context --target <page-kit-dir> [--dry-run]
493
- campaigns-os next --packet <json> [--no-write] [--no-remit] [--proxy-base <url>] [--json] # self-decide next stage; returns gates[] + next_actions[] (exact commands) alongside the prompt
495
+ campaigns-os next [${NEXT_STAGE_ORDER.join("|")}] --packet <json> [--no-write] [--no-remit] [--proxy-base <url>] [--json] # no stage self-decides; returns gates[] + next_actions[] alongside the prompt
494
496
  campaigns-os next setup --packet <json> [--context <json>] [--report <json>] [--json]
495
497
  campaigns-os next build --packet <json> [--context <json>] [--report <json>] [--json]
496
498
  campaigns-os next polish --packet <json> --report <json> [--json]
@@ -654,6 +656,18 @@ export async function main(argv, { authentication } = {}) {
654
656
  return;
655
657
  }
656
658
 
659
+ // Project setup must not recover campaign sessions, read gateway bindings,
660
+ // or emit lifecycle/telemetry evidence before a campaign is selected.
661
+ if (command === "tooling" && args._[1] === "setup") {
662
+ const { setupArguments, setupTooling, setupTextLines } = await import("./tooling-setup.mjs");
663
+ setupArguments(args, argv);
664
+ const { installQaBrowser } = await import("./qa-node.mjs");
665
+ const result = setupTooling(args, { packageRoot: ROOT, installSkills, installAgentContext, installBrowser: installQaBrowser });
666
+ console.log(args.json ? JSON.stringify(result, null, 2) : setupTextLines(result).join("\n"));
667
+ if (!result.ok) process.exitCode = 2;
668
+ return;
669
+ }
670
+
657
671
  // Ambient run session (Tier 3): when `run start` is active, every command
658
672
  // shares its run_id WITHOUT --run-id. Explicit --run-id still wins. Resolved
659
673
  // ONCE here and threaded through dispatch + persistence so the run_id a
@@ -959,31 +973,38 @@ export function recordQaStageOutcome(args, result) {
959
973
  if (!existsSync(reportPath)) return false;
960
974
 
961
975
  const verdict = result.verdict;
976
+ const hasLocalIdentity = packet.spec?.local_spec_id != null || verdict?.local_spec_id != null;
977
+ if (hasLocalIdentity && !qaVerdictIdentityMatch(verdict, packet)) return false;
962
978
  const failed = (Array.isArray(verdict.assertions) ? verdict.assertions : [])
963
979
  .filter((assertion) => assertion?.status === "fail")
964
980
  .map((assertion) => `${assertion.id}: ${assertion.actual || "assertion failed"}`);
965
- const committed = commitAssemblyReport(workspace, (report) => recordProducerStageOutcome(report, {
966
- stage: "qa",
967
- disposition: verdict.disposition,
968
- timestamp: verdict.completed_at,
969
- command: `campaigns-os ${QA_RUN_PRODUCER}`,
970
- outputs: [result.local_path, result.qa_sidecar?.path].filter(isNonEmptyString),
971
- blockers: verdict.disposition === "blocked" ? failed : [],
972
- warnings: verdict.disposition === "ready_with_exceptions"
973
- ? ["QA completed with explicitly attributed exceptions; inspect the verdict artifact."]
974
- : [],
975
- // The producer knows its own run id and must restate it, or the stage
976
- // keeps a previous run's identity beside this run's status and outputs.
977
- identity: { verdict_run_id: optionalString(verdict.run_id) },
978
- // Which build this verdict judged, and the gates whose browser outcome
979
- // the doctor's static scan defers to (qaGatePassedForCurrentBuild). A
980
- // gate that never ran is left out, so silence never reads as a pass.
981
- evidence: qaStageGateEvidence(verdict, report),
982
- // Counts-only: never order ids, refs, emails or URLs (see
983
- // summarizePurchaseProof). This is what lets `next` tell a real purchase
984
- // path from a `--test-order off` diagnostic.
985
- proof: summarizePurchaseProof({ verdict, proofPolicy: packet.qa?.proof_policy }),
986
- }), {
981
+ const committed = commitAssemblyReport(workspace, (report) => {
982
+ if (hasLocalIdentity && !specHashesMatch(verdict.spec_hash, report.identity?.spec_material_hash)) {
983
+ throw new Error("Local-spec QA verdict belongs to a different material revision; report evidence was not changed.");
984
+ }
985
+ return recordProducerStageOutcome(report, {
986
+ stage: "qa",
987
+ disposition: verdict.disposition,
988
+ timestamp: verdict.completed_at,
989
+ command: `campaigns-os ${QA_RUN_PRODUCER}`,
990
+ outputs: [result.local_path, result.qa_sidecar?.path].filter(isNonEmptyString),
991
+ blockers: verdict.disposition === "blocked" ? failed : [],
992
+ warnings: verdict.disposition === "ready_with_exceptions"
993
+ ? ["QA completed with explicitly attributed exceptions; inspect the verdict artifact."]
994
+ : [],
995
+ // The producer knows its own run id and must restate it, or the stage
996
+ // keeps a previous run's identity beside this run's status and outputs.
997
+ identity: { verdict_run_id: optionalString(verdict.run_id) },
998
+ // Which build this verdict judged, and the gates whose browser outcome
999
+ // the doctor's static scan defers to (qaGatePassedForCurrentBuild). A
1000
+ // gate that never ran is left out, so silence never reads as a pass.
1001
+ evidence: qaStageGateEvidence(verdict, report),
1002
+ // Counts-only: never order ids, refs, emails or URLs (see
1003
+ // summarizePurchaseProof). This is what lets `next` tell a real purchase
1004
+ // path from a `--test-order off` diagnostic.
1005
+ proof: summarizePurchaseProof({ verdict, proofPolicy: packet.qa?.proof_policy }),
1006
+ });
1007
+ }, {
987
1008
  stage: "qa",
988
1009
  // The sidecar names this refresh as its producer (generated_by, #312).
989
1010
  // This function is the `qa run` stage record, whichever token dispatch
@@ -1167,6 +1188,23 @@ async function dispatch(command, args, recorder = NOOP_RECORDER, ambient = null,
1167
1188
  // them from this function's source.
1168
1189
  const mode = PREPARE_MODES[command];
1169
1190
  if (!mode) throw new Error(`No intake mode registered for "${command}"; add it to PREPARE_MODES.`);
1191
+ // Validate argv before --spec is inspected or --map-id fetches and caches.
1192
+ // Preserve resolveSpecPath's missing-input and map-id/target diagnostics.
1193
+ for (const flag of ["spec", "map-id", "source", "target", "source-kind", "proxy-base"]) {
1194
+ if (Object.hasOwn(args, flag)) requireArg(args, flag);
1195
+ }
1196
+ if (args.spec || (args["map-id"] && args.target)) requireArg(args, "source");
1197
+ if (args.spec) requireArg(args, "target");
1198
+ const sourceKind = optionalString(args["source-kind"], "html_funnel");
1199
+ if (sourceKind !== "html_funnel") {
1200
+ throw refused(`Unsupported source adapter "${sourceKind}". Use html_funnel for the current prepared-HTML flow.`);
1201
+ }
1202
+ if (Object.hasOwn(args, "wrapper-policy") && !isNonEmptyString(args["wrapper-policy"])) {
1203
+ throw refused(`--wrapper-policy needs a value. Accepted values: ${ADAPTER_WRAPPER_POLICIES.join(", ")}.`);
1204
+ }
1205
+ const wrapperPolicyFlag = refusing(() => parseWrapperPolicyFlag(args));
1206
+ refusing(() => requireDesignManifestValue(args));
1207
+ const orderPathDepthFlag = refusing(() => parseOrderPathDepthFlag(args, { command: "prepare-build" }));
1170
1208
  // Tier 2: mark sub-phases so the lifecycle journal entry carries per-phase
1171
1209
  // timings (spec resolve vs the prepare+doctor+install build), which Tier 1
1172
1210
  // aggregates into `start:resolve-spec` / `start:prepare-build` stages.
@@ -1178,7 +1216,7 @@ async function dispatch(command, args, recorder = NOOP_RECORDER, ambient = null,
1178
1216
  args.spec = resolved.specPath;
1179
1217
  // `command` rides along for the doctor sidecar's generated_by stamp when
1180
1218
  // the mode runs doctor (#312): threaded from here, not re-read from argv.
1181
- const result = await recorder.time("prepare-build", () => prepareBuild(args, { ...mode, command, specInput }));
1219
+ const result = await recorder.time("prepare-build", () => prepareBuild(args, { ...mode, command, specInput, sourceKind, wrapperPolicyFlag, orderPathDepthFlag }));
1182
1220
  result.spec_source = resolved;
1183
1221
  autoStartRunSession(result, args, ambient, sessionHolder);
1184
1222
  printPrepareResult(result, args);
@@ -1346,7 +1384,9 @@ async function dispatch(command, args, recorder = NOOP_RECORDER, ambient = null,
1346
1384
  // command a QA run prints has to agree with the run_id this session will
1347
1385
  // later close and remit under.
1348
1386
  const result = await runQaCli(args, { ambient });
1349
- if (args._[1] === "run" && result?.verdict && recordQaStageOutcome(args, result)) {
1387
+ // nextStage requires a packet. Guard its optional, swallowed progress
1388
+ // probe explicitly so it cannot construct a refusal in that try block.
1389
+ if (args._[1] === "run" && result?.verdict && isNonEmptyString(args.packet) && recordQaStageOutcome(args, result)) {
1350
1390
  // Observe committed QA before the existing closeout; this cannot close
1351
1391
  // a run or change the QA disposition. Reuse the canonical picker.
1352
1392
  try {
@@ -1364,6 +1404,9 @@ async function dispatch(command, args, recorder = NOOP_RECORDER, ambient = null,
1364
1404
  }
1365
1405
 
1366
1406
  if (command === "run-record") {
1407
+ // These are the operator's own argv. Internal closeouts call
1408
+ // runRecordCommand directly and retain their pre-1.43.1 flag handling.
1409
+ validateRunRecordArgv(args);
1367
1410
  await runRecordCommand(args, ambient);
1368
1411
  return;
1369
1412
  }
@@ -1675,7 +1718,7 @@ function campaignIdentity(spec, args) {
1675
1718
  || optionalString(spec.spec_identity?.public_route_slug)
1676
1719
  || optionalString(spec.campaign?.slug)
1677
1720
  || optionalString(spec.campaign?.id);
1678
- return { mapId, publicRouteSlug };
1721
+ return { mapId, publicRouteSlug, localSpecId: spec.spec_identity?.local_spec_id ?? null };
1679
1722
  }
1680
1723
 
1681
1724
  function preferredTemplateFamily(spec) {
@@ -2288,11 +2331,8 @@ function parseWrapperPolicyFlag(args) {
2288
2331
  // directory are errors: the operator named the file, so silently falling back
2289
2332
  // to filesystem matching would discard the declaration they made.
2290
2333
  function parseDesignManifestFlag(args) {
2291
- const raw = args["design-manifest"];
2334
+ const raw = requireDesignManifestValue(args);
2292
2335
  if (raw == null) return null;
2293
- if (raw === true || !isNonEmptyString(raw)) {
2294
- throw new Error("--design-manifest needs a value: the path of a source-html-manifest/v0 JSON file.");
2295
- }
2296
2336
  const path = resolve(raw);
2297
2337
  if (!existsSync(path) || !statSync(path).isFile()) {
2298
2338
  throw new Error(`Design manifest does not exist or is not a file: ${path}`);
@@ -2300,6 +2340,14 @@ function parseDesignManifestFlag(args) {
2300
2340
  return path;
2301
2341
  }
2302
2342
 
2343
+ function requireDesignManifestValue(args) {
2344
+ const raw = args["design-manifest"];
2345
+ if (raw != null && (raw === true || !isNonEmptyString(raw))) {
2346
+ throw new Error("--design-manifest needs a value: the path of a source-html-manifest/v0 JSON file.");
2347
+ }
2348
+ return raw;
2349
+ }
2350
+
2303
2351
  // Same precedence the template family uses (docs/build-packet.md
2304
2352
  // "Authoring-Time Hints"): an explicit CLI flag beats a declared file hint,
2305
2353
  // and with neither the default stands. The vocabulary is the adapter
@@ -2350,21 +2398,18 @@ function prepareBuild(args, options = {}) {
2350
2398
  assertDistinctPrepareBuildOutputPaths(prepareBuildCollisionPaths);
2351
2399
  guardAssemblyReportOverwrite(reportPath, args);
2352
2400
  const spec = readJson(specPath);
2353
- const { mapId, publicRouteSlug } = campaignIdentity(spec, args);
2354
- if (!mapId) throw new Error("CampaignSpec has no map ID. Re-export a saved Map Builder spec with spec_identity.map_id before assembly; use --map-id only for legacy diagnostics.");
2355
- if (!publicRouteSlug) throw new Error("CampaignSpec has no public route slug. Re-export a saved Map Builder spec with spec_identity.public_route_slug or set campaign.slug.");
2356
-
2357
- const sourceKind = optionalString(args["source-kind"], "html_funnel");
2358
- if (sourceKind !== "html_funnel") {
2359
- throw new Error(`Unsupported source adapter "${sourceKind}". Use html_funnel for the current prepared-HTML flow.`);
2360
- }
2361
- // Validated here, with the other argv checks, rather than where the policy
2362
- // is consumed: prepare-build publishes an immutable Design Source Package
2363
- // partway through, so a flag that throws later would leave persistent state
2364
- // behind for a bad argument.
2365
- const wrapperPolicyFlag = parseWrapperPolicyFlag(args);
2401
+ const { mapId, publicRouteSlug, localSpecId } = campaignIdentity(spec, args);
2402
+ if (!resolveCampaignIdentity({ map_id: mapId, local_spec_id: localSpecId })) {
2403
+ throw new Error("CampaignSpec requires exactly one identity: a saved spec_identity.map_id, or an agent-authored spec_identity.local_spec_id (1–64 letters, digits, underscores or hyphens). Keep the local ID stable across revisions; do not invent a Map ID.");
2404
+ }
2405
+ if (!publicRouteSlug) throw new Error("CampaignSpec has no public route slug. Set spec_identity.public_route_slug or campaign.slug.");
2406
+
2407
+ // Dispatch validated the argv-only flags before spec resolution. Only the
2408
+ // manifest's filesystem check remains here, before preparation writes.
2409
+ const sourceKind = options.sourceKind;
2410
+ const wrapperPolicyFlag = options.wrapperPolicyFlag;
2366
2411
  const designManifestPath = parseDesignManifestFlag(args);
2367
- const orderPathDepthFlag = parseOrderPathDepthFlag(args, { command: "prepare-build" });
2412
+ const orderPathDepthFlag = options.orderPathDepthFlag;
2368
2413
 
2369
2414
  const activePages = activeSpecPages(spec);
2370
2415
  const htmlFiles = collectHtmlFiles(sourceRoot);
@@ -2600,7 +2645,8 @@ function prepareBuild(args, options = {}) {
2600
2645
  },
2601
2646
  spec: {
2602
2647
  map_id: mapId,
2603
- spec_url: spec.spec_identity?.spec_url || null,
2648
+ ...(localSpecId ? { local_spec_id: localSpecId } : {}),
2649
+ spec_url: localSpecId ? null : spec.spec_identity?.spec_url || null,
2604
2650
  local_path: relFromFile(packetPath, specPath),
2605
2651
  },
2606
2652
  design_source_package: designSourcePackage.referenceFor(packetPath),
@@ -2965,6 +3011,7 @@ function createAssemblyReport({
2965
3011
  status: "prepared",
2966
3012
  identity: {
2967
3013
  map_id: packet.spec.map_id,
3014
+ ...localSpecIdentityFields(packet.spec),
2968
3015
  public_route_slug: packet.campaign.public_route_slug,
2969
3016
  campaign_directory: packet.campaign.campaign_directory,
2970
3017
  live_url_path: packet.campaign.live_url_path,
@@ -3860,11 +3907,17 @@ export function doctorPacket(packetPath, options = {}) {
3860
3907
  // to go by first. Code granularity, because that is the granularity the
3861
3908
  // record stores. baseDir is the packet directory, the same root the Run
3862
3909
  // Record writes under.
3910
+ // An invalid identity cannot select history. In particular, withholding a
3911
+ // malformed local ID from derived must not turn it into an unfiltered or
3912
+ // Map-only lookup of another campaign's findings.
3913
+ const comparableIdentity = resolveCampaignIdentity(result.derived)
3914
+ && !result.errors.some(issue => issue.code === "spec.local_identity" || issue.code === "spec.map_id");
3863
3915
  result.cause_summary = annotateDoctorIssueCauses({
3864
3916
  errors: result.errors,
3865
3917
  warnings: result.warnings,
3866
- baseDir: dirname(resolve(packetPath)),
3918
+ baseDir: comparableIdentity ? dirname(resolve(packetPath)) : null,
3867
3919
  mapId: result.derived?.map_id || null,
3920
+ localSpecId: result.derived?.local_spec_id || null,
3868
3921
  });
3869
3922
  return result;
3870
3923
  }
@@ -3884,6 +3937,7 @@ function inspectDoctorPacket(packetPath, { contextPath = undefined, reportPath =
3884
3937
  const errors = [];
3885
3938
  const warnings = [];
3886
3939
  const ready = [];
3940
+ const packetIdentity = resolveCampaignIdentity(packet?.spec);
3887
3941
  const derived = {
3888
3942
  packet_path: packetPath,
3889
3943
  // The report this inspection read (null when the caller switched the
@@ -3891,6 +3945,7 @@ function inspectDoctorPacket(packetPath, { contextPath = undefined, reportPath =
3891
3945
  // different file.
3892
3946
  assembly_report_path: typeof resolvedReportPath === "string" ? resolvedReportPath : null,
3893
3947
  map_id: packet?.spec?.map_id || null,
3948
+ ...(packetIdentity?.kind === "local_spec" ? { local_spec_id: packetIdentity.id } : {}),
3894
3949
  public_route_slug: packet?.campaign?.public_route_slug || null,
3895
3950
  template_family: packet?.assembly?.template_family || null,
3896
3951
  source_root: null,
@@ -4324,7 +4379,10 @@ function validatePacket(packet, packetPath, errors, warnings, ready, derived, bu
4324
4379
 
4325
4380
  requireString(packet, errors, "campaign.public_route_slug");
4326
4381
  requireBoolean(packet, errors, "campaign.allowed_domains_confirmed");
4327
- requireString(packet, errors, "spec.map_id");
4382
+ if (!resolveCampaignIdentity(packet.spec)) addIssue(errors, packet.spec?.local_spec_id != null ? "spec.local_identity" : "spec.map_id", "Packet spec requires exactly one valid map_id or local_spec_id.", { kind: packet.spec?.local_spec_id != null ? "local_spec" : "saved_map" });
4383
+ if (packet.spec?.local_spec_id != null && (packet.spec.spec_url != null || !isNonEmptyString(packet.spec.local_path))) {
4384
+ addIssue(errors, "spec.local_identity", "Local-spec packets require a local_path and no saved-Map spec_url.");
4385
+ }
4328
4386
  if (!synthesizedBuiltSite) {
4329
4387
  requireString(packet, errors, "source_html.root");
4330
4388
  requireArray(packet, errors, "source_html.pages");
@@ -4510,8 +4568,11 @@ function validatePacket(packet, packetPath, errors, warnings, ready, derived, bu
4510
4568
  buildState.specStatus = specStatus;
4511
4569
  if (specStatus === "ok") {
4512
4570
  const specMapId = spec.spec_identity?.map_id || spec.map_id;
4513
- if (specMapId && specMapId !== packet.spec.map_id) {
4514
- addIssue(errors, "spec.map_id", `Packet map_id "${packet.spec.map_id}" does not match CampaignSpec map_id "${specMapId}".`);
4571
+ if ((specMapId && specMapId !== packet.spec.map_id)
4572
+ || ((spec.spec_identity?.local_spec_id != null || packet.spec?.local_spec_id != null)
4573
+ && !campaignIdentitiesMatch(campaignSpecIdentity(spec), packet.spec))) {
4574
+ const localIdentity = spec.spec_identity?.local_spec_id != null || packet.spec?.local_spec_id != null;
4575
+ addIssue(errors, localIdentity ? "spec.local_identity" : "spec.map_id", "Packet identity does not match the CampaignSpec map_id/local_spec_id.", { kind: localIdentity ? "local_spec" : "saved_map" });
4515
4576
  }
4516
4577
  ready.push("Local CampaignSpec parsed");
4517
4578
  runDoctorChecks(SPEC_DOCTOR_CHECKS, { packet, packetPath, spec, targetRepo, errors, warnings, ready, derived, buildState });
@@ -5006,6 +5067,9 @@ export function pageKitSyncCommand(args) {
5006
5067
  addIssue(result.errors, "page_kit.sync.spec_identity_mismatch", `CampaignSpec identifies route "${singleLineField(specSlug)}" but the packet's campaign.public_route_slug is "${publicRouteSlug}". Point spec.local_path at this campaign's export (or re-run prepare-build from it); nothing was written.`);
5007
5068
  } else if (specMapId && packetMapId && specMapId !== packetMapId) {
5008
5069
  addIssue(result.errors, "page_kit.sync.spec_identity_mismatch", `CampaignSpec spec_identity.map_id "${singleLineField(specMapId)}" does not match the packet's spec.map_id "${singleLineField(packetMapId)}". Point spec.local_path at this campaign's export (or re-run prepare-build from it); nothing was written.`);
5070
+ } else if ((spec.spec_identity?.local_spec_id != null || packet.spec?.local_spec_id != null)
5071
+ && !campaignIdentitiesMatch(campaignSpecIdentity(spec), packet.spec)) {
5072
+ addIssue(result.errors, "page_kit.sync.spec_identity_mismatch", "CampaignSpec identity (spec_identity.map_id/local_spec_id) does not match the packet identity. Point spec.local_path at this campaign's spec (or re-run prepare-build from it); nothing was written.");
5009
5073
  }
5010
5074
  }
5011
5075
 
@@ -5349,6 +5413,9 @@ export function specDeriveCommand(args, { store: storeRead = null } = {}) {
5349
5413
  addIssue(result.errors, "spec.derive.spec_identity_mismatch", `CampaignSpec identifies route "${singleLineField(specSlug)}" but the packet's campaign.public_route_slug is "${publicRouteSlug}". Point spec.local_path at this campaign's export (or re-run prepare-build from it); nothing was written.`);
5350
5414
  } else if (specMapId && packetMapId && specMapId !== packetMapId) {
5351
5415
  addIssue(result.errors, "spec.derive.spec_identity_mismatch", `CampaignSpec spec_identity.map_id "${singleLineField(specMapId)}" does not match the packet's spec.map_id "${singleLineField(packetMapId)}". Point spec.local_path at this campaign's export (or re-run prepare-build from it); nothing was written.`);
5416
+ } else if ((spec.spec_identity?.local_spec_id != null || packet.spec?.local_spec_id != null)
5417
+ && !campaignIdentitiesMatch(campaignSpecIdentity(spec), packet.spec)) {
5418
+ addIssue(result.errors, "spec.derive.spec_identity_mismatch", "CampaignSpec identity (spec_identity.map_id/local_spec_id) does not match the packet identity. Point spec.local_path at this campaign's spec (or re-run prepare-build from it); nothing was written.");
5352
5419
  }
5353
5420
  }
5354
5421
 
@@ -6133,15 +6200,15 @@ function validateSpecPackageAvailability(spec, warnings, ready) {
6133
6200
 
6134
6201
  function validateSpecIdentityExport(spec, warnings, ready) {
6135
6202
  const identity = spec?.spec_identity;
6136
- if (isObject(identity) && isNonEmptyString(identity.map_id) && isNonEmptyString(identity.public_route_slug)) {
6137
- ready.push("CampaignSpec spec_identity includes map_id and public_route_slug");
6203
+ if (isObject(identity) && resolveCampaignIdentity(identity) && isNonEmptyString(identity.public_route_slug)) {
6204
+ ready.push(`CampaignSpec spec_identity includes ${identity.local_spec_id ? "local_spec_id" : "map_id"} and public_route_slug`);
6138
6205
  return;
6139
6206
  }
6140
6207
 
6141
6208
  addIssue(
6142
6209
  warnings,
6143
6210
  "spec_identity.export",
6144
- "CampaignSpec is missing complete spec_identity.map_id/public_route_slug. Prefer re-exporting from a saved Map Builder map; CLI identity overrides should stay diagnostic-only."
6211
+ "CampaignSpec is missing complete spec_identity: declare map_id for a saved Map or local_spec_id for a local spec, plus public_route_slug. CLI identity overrides should stay diagnostic-only."
6145
6212
  );
6146
6213
  }
6147
6214
 
@@ -9024,7 +9091,12 @@ function validateAssemblyReport(report, { checkSourcePackageFreshness = true } =
9024
9091
  if (report.schema_version !== REPORT_SCHEMA) addIssue(errors, "schema_version", `Expected ${REPORT_SCHEMA}.`);
9025
9092
  else ready.push(`Assembly report schema ${REPORT_SCHEMA}`);
9026
9093
  for (const path of ["run_id", "generated_at", "status", "identity.map_id", "identity.public_route_slug", "inputs.packet_path", "template_family.value"]) {
9027
- requireString(report, errors, path);
9094
+ if (path === "identity.map_id") {
9095
+ if (!resolveCampaignIdentity(report.identity)) addIssue(errors, "identity.map_id", "Assembly Report requires exactly one valid map_id or local_spec_id.");
9096
+ } else requireString(report, errors, path);
9097
+ }
9098
+ if (report.identity?.local_spec_id != null && !/^sha256:[0-9a-f]{64}$/.test(report.identity.spec_material_hash || "")) {
9099
+ addIssue(errors, "identity.spec_material_hash", "Local-spec reports require a current SHA-256 material spec hash.");
9028
9100
  }
9029
9101
  const stages = report.stages;
9030
9102
  if (!isObject(stages)) {
@@ -9274,13 +9346,14 @@ function nextPrepareBuildBindingIssues({
9274
9346
  const expectedSlug = optionalString(packet.campaign?.public_route_slug);
9275
9347
  const recordedMapId = optionalString(report.identity?.map_id);
9276
9348
  const recordedSlug = optionalString(report.identity?.public_route_slug);
9277
- if (recordedMapId !== expectedMapId || recordedSlug !== expectedSlug) {
9349
+ if (!campaignIdentitiesMatch(packet.spec, report.identity) || recordedSlug !== expectedSlug) {
9278
9350
  push(
9279
9351
  "next.prepare_build.report_campaign_mismatch",
9280
9352
  "Assembly Report campaign identity does not match the current Build Packet.",
9281
9353
  {
9282
- expected: { map_id: expectedMapId, public_route_slug: expectedSlug },
9283
- recorded: { map_id: recordedMapId, public_route_slug: recordedSlug },
9354
+ // Failed identity fields are diagnostic data, never adopted evidence.
9355
+ expected: { map_id: expectedMapId, local_spec_id: packet.spec?.local_spec_id ?? null, public_route_slug: expectedSlug },
9356
+ recorded: { map_id: recordedMapId, local_spec_id: report.identity?.local_spec_id ?? null, public_route_slug: recordedSlug },
9284
9357
  },
9285
9358
  );
9286
9359
  }
@@ -9689,6 +9762,9 @@ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate
9689
9762
  }
9690
9763
 
9691
9764
  export function nextStage(stage, args, ambient = null) {
9765
+ if (stage !== null && !NEXT_STAGE_ORDER.includes(stage)) {
9766
+ throw refused(`Unknown next stage: ${stage}. Accepted stages: ${NEXT_STAGE_ORDER.join(", ")}.`);
9767
+ }
9692
9768
  const packetPath = resolve(requireArg(args, "packet"));
9693
9769
  // A custom prepare-build report is recorded on the Build Context relative
9694
9770
  // to the target repo. Packet-only `next` must follow that durable pointer;
@@ -9894,8 +9970,6 @@ export function nextStage(stage, args, ambient = null) {
9894
9970
  addPolishCheckpointGateErrors(errors, polishCheckpointGate, "qa");
9895
9971
  addThemeGateErrors(errors, themeGate, "qa");
9896
9972
  prompt = qaPrompt(packetPath, reportPath, packet);
9897
- } else {
9898
- throw new Error(`Unknown next stage: ${stage}`);
9899
9973
  }
9900
9974
  const status = errors.length
9901
9975
  ? "blocked"
@@ -10602,7 +10676,7 @@ function qaPrompt(packetPath, reportPath, packet) {
10602
10676
  const briefPath = packet.build_brief?.normalized_path || "(missing)";
10603
10677
  return `Use next-campaigns-qa for this deployed campaign.
10604
10678
 
10605
- Map ID: ${packet.spec.map_id}
10679
+ ${packet.spec.local_spec_id ? "Local spec ID" : "Map ID"}: ${packet.spec.local_spec_id || packet.spec.map_id}
10606
10680
  Base URL: ${url}
10607
10681
  Build Packet: ${packetPath}
10608
10682
  Assembly Report: ${reportPath}
@@ -12609,6 +12683,12 @@ function runSessionProgress(found) {
12609
12683
  }
12610
12684
 
12611
12685
  async function runSessionEnd(args, ambient = null, sessionHolder = null) {
12686
+ // The closer reads a session, but its own argv refusals still belong to the
12687
+ // invoking command. Check them before entering closeRunSession's nested scope.
12688
+ if (Object.hasOwn(args, "new-run") || Object.hasOwn(args, "run-id")) {
12689
+ throw refused("run end uses the saved session's run ID; --new-run and --run-id are not accepted.");
12690
+ }
12691
+ validateRunRecordArgv(args);
12612
12692
  // Use the session resolved once in main() (single source of truth).
12613
12693
  const found = ambient;
12614
12694
  if (!found) {
@@ -12643,11 +12723,15 @@ async function runSessionEnd(args, ambient = null, sessionHolder = null) {
12643
12723
  // invoking command implements the flag; a closer invoked by a command that
12644
12724
  // does not (the QA auto-end) drops it from extraArgs before calling — see
12645
12725
  // DRY_RUN_COMMANDS and autoEndRunSessionAfterTerminalQa.
12646
- const RUN_RECORD_INHERITABLE_FLAGS = Object.freeze([
12726
+ export const RUN_RECORD_INHERITABLE_FLAGS = Object.freeze([
12647
12727
  "context", "report", "qa-verdict", "journal", "surfaces", "primary-surface", "surface-confidence",
12648
12728
  "agent-input-tokens", "agent-output-tokens", "agent-tool-output-tokens", "agent-total-tokens", "agent-elapsed-ms", "agent-model", "agent-usage-source",
12649
12729
  "no-remit", "no-write", "proxy-base", "dry-run", "json",
12650
12730
  ]);
12731
+ const RUN_RECORD_BOOLEAN_INHERITABLE_FLAGS = new Set(["no-remit", "no-write", "dry-run", "json"]);
12732
+ const RUN_RECORD_INTEGER_INHERITABLE_FLAGS = new Set([
12733
+ "agent-input-tokens", "agent-output-tokens", "agent-tool-output-tokens", "agent-total-tokens", "agent-elapsed-ms",
12734
+ ]);
12651
12735
 
12652
12736
  // The run-record argv that closes `session`: its run_id and journal, the
12653
12737
  // packet the closer resolved, and only the inheritable flags of `extraArgs`.
@@ -12674,15 +12758,23 @@ export function runSessionEndArgs(session, packet, extraArgs = {}) {
12674
12758
  async function closeRunSession(found, { packet, extraArgs = {}, silent = false, promptForConsent = true, onError = null } = {}) {
12675
12759
  const endArgs = runSessionEndArgs(found.session, packet, extraArgs);
12676
12760
  try {
12677
- const summary = await runRecordCommand(endArgs, found, { silent, promptForConsent });
12761
+ // No internal closeout currently constructs a refusal before its invoking
12762
+ // command journals: the sweep forwards only remit controls tolerated by
12763
+ // run-record, run end validates its own argv first, and QA auto-end runs
12764
+ // after QA persistence. Keep the scope at this boundary so a future
12765
+ // closeout refusal swallowed by onError cannot mark the outer invocation.
12766
+ const summary = await runWithRefusalScope(() => runRecordCommand(endArgs, found, { silent, promptForConsent }));
12678
12767
  // Clearing the session is a write like any other, so a closer carrying
12679
12768
  // --dry-run leaves it open: the operator sees the record the close would
12680
12769
  // assemble and can still close for real afterwards.
12681
12770
  if (endArgs["dry-run"] !== true) clearRunSession(found.path);
12682
12771
  return summary;
12683
12772
  } catch (error) {
12684
- if (!onError) throw error;
12685
- onError(error);
12773
+ // A nested run-record refusal is a failure of the invoking closer, which
12774
+ // has already read session state. Do not pass its tag to outer persistence.
12775
+ const failure = error?.code === REFUSED_INVOCATION ? new Error(error.message, { cause: error }) : error;
12776
+ if (!onError) throw failure;
12777
+ onError(failure);
12686
12778
  return null;
12687
12779
  }
12688
12780
  }
@@ -12892,6 +12984,10 @@ export function describeCampaignKeyRejection(rejected) {
12892
12984
  // docs/workflow-findings-sidecar.md.
12893
12985
  async function runRecordCommand(args, ambient = null, { silent = false, promptForConsent = true } = {}) {
12894
12986
  const packetPath = resolve(requireArg(args, "packet"));
12987
+ if (args["new-run"] === true && optionalString(args["run-id"])) {
12988
+ throw refused("run-record: --new-run and --run-id are exclusive; --run-id names the run to re-emit, --new-run mints a fresh one.");
12989
+ }
12990
+ const agentUsage = refusing(() => parseAgentUsageArgs(args));
12895
12991
  // --dry-run assembles the record and shows it, then writes and sends
12896
12992
  // nothing. It differs from --no-write, which skips the assembly's reads
12897
12993
  // as well; combining the two is allowed and still writes nothing.
@@ -12919,10 +13015,12 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
12919
13015
  : inferQaVerdictPath({ packet, report, reportPath: reportExists ? reportPath : null, targetRepo, baseDir });
12920
13016
  const qaVerdictExists = qaVerdictPath != null && existsSync(qaVerdictPath);
12921
13017
  const qaVerdict = qaVerdictExists ? readJson(qaVerdictPath) : null;
13018
+ if (qaVerdict && (packet.spec?.local_spec_id != null || qaVerdict.local_spec_id != null) && !qaVerdictIdentityMatch(qaVerdict, packet)) {
13019
+ throw new Error("run-record: QA verdict does not match this packet's local_spec_id; foreign evidence cannot be recorded as this local campaign.");
13020
+ }
12922
13021
 
12923
13022
  const journalPath = resolveJournalPath(args);
12924
13023
  const journal = readJournal(journalPath);
12925
- const agentUsage = parseAgentUsageArgs(args);
12926
13024
 
12927
13025
  // run_id: explicit flag > --new-run (mint) > active run session > the most
12928
13026
  // recent Run Record on disk for this packet's campaign > freshly minted.
@@ -12935,9 +13033,6 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
12935
13033
  // record for this campaign exists, or when --new-run asks for it. The
12936
13034
  // source travels on the stdout envelope only: the record's schema is
12937
13035
  // hashed surface and does not carry it.
12938
- if (args["new-run"] === true && optionalString(args["run-id"])) {
12939
- throw new Error("run-record: --new-run and --run-id are exclusive; --run-id names the run to re-emit, --new-run mints a fresh one.");
12940
- }
12941
13036
  const listOnly = args.list === true;
12942
13037
  // The directory is scanned only when something reads it: --list, or an id
12943
13038
  // that nothing else names. An explicit --run-id, --new-run or an open
@@ -13134,6 +13229,7 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
13134
13229
  consent: { state: consent.state, source: consent.source },
13135
13230
  identity: {
13136
13231
  map_id: optionalString(packet.spec?.map_id),
13232
+ ...localSpecIdentityFields(packet.spec),
13137
13233
  campaign_slug: optionalString(packet.campaign?.public_route_slug),
13138
13234
  template_family: optionalString(packet.assembly?.template_family),
13139
13235
  entry_point_shape: "packet",
@@ -13467,7 +13563,8 @@ function inferQaVerdictPath({ packet, report, reportPath = null, targetRepo = nu
13467
13563
  report,
13468
13564
  reportPath: reportPath || (targetRepo ? campaignSidecarPaths(targetRepo).reportPath : null),
13469
13565
  roots: [targetRepo, baseDir],
13470
- }).filter((candidate) => candidate.verdict && candidate.trusted);
13566
+ }).filter((candidate) => candidate.verdict && candidate.trusted
13567
+ && ((packet?.spec?.local_spec_id == null && candidate.verdict.local_spec_id == null) || candidate.identityMatch));
13471
13568
  eligible.sort((a, b) => {
13472
13569
  const scoreDelta = qaVerdictCandidateScore(b, packet) - qaVerdictCandidateScore(a, packet);
13473
13570
  if (scoreDelta !== 0) return scoreDelta;
@@ -13508,6 +13605,26 @@ function parseRunRecordSurfaces(value) {
13508
13605
  return surfaces;
13509
13606
  }
13510
13607
 
13608
+ function validateRunRecordArgv(args) {
13609
+ for (const flag of RUN_RECORD_INHERITABLE_FLAGS) {
13610
+ // The integer flags are checked below by parseAgentUsageArgs, which keeps
13611
+ // their non-negative-integer diagnostics for bare and blank values.
13612
+ if (!RUN_RECORD_BOOLEAN_INHERITABLE_FLAGS.has(flag) && !RUN_RECORD_INTEGER_INHERITABLE_FLAGS.has(flag) && Object.hasOwn(args, flag)) requireArg(args, flag);
13613
+ }
13614
+ if (Object.hasOwn(args, "run-id")) requireArg(args, "run-id");
13615
+ if (Object.hasOwn(args, "new-run") && args["new-run"] !== true) {
13616
+ throw refused("--new-run takes no value.");
13617
+ }
13618
+ if (args["new-run"] === true && optionalString(args["run-id"])) {
13619
+ throw refused("run-record: --new-run and --run-id are exclusive; --run-id names the run to re-emit, --new-run mints a fresh one.");
13620
+ }
13621
+ return {
13622
+ agentUsage: refusing(() => parseAgentUsageArgs(args)),
13623
+ dryRun: isDryRun(args),
13624
+ parsedSurfaces: parseRunRecordSurfaces(args.surfaces),
13625
+ };
13626
+ }
13627
+
13511
13628
  function parseAgentUsageArgs(args) {
13512
13629
  const fields = {
13513
13630
  "agent-input-tokens": "input_tokens",
@@ -1,3 +1,4 @@
1
+ import { campaignIdentitiesMatch } from "./spec-source-identity.mjs";
1
2
  // Per-finding cause class — "did the change under test cause this?"
2
3
  //
3
4
  // A run that surfaces eleven findings, none of them caused by the change being
@@ -340,12 +341,13 @@ import { readRunRecordsForTarget } from "./run-record.mjs";
340
341
  * against a non-adjacent one and report findings introduced in between as
341
342
  * pre-existing.
342
343
  */
343
- export function findPriorRunRecord({ baseDir, mapId = null, currentRunId = null } = {}) {
344
+ export function findPriorRunRecord({ baseDir, mapId = null, localSpecId = null, currentRunId = null } = {}) {
344
345
  if (!text(baseDir)) return null;
345
346
  for (const entry of readRunRecordsForTarget(baseDir)) {
346
347
  const record = entry?.record;
347
348
  if (!record || typeof record !== "object" || Array.isArray(record)) continue;
348
349
  if (currentRunId && record.run_id === currentRunId) continue;
350
+ if (localSpecId && !campaignIdentitiesMatch(record.identity, { local_spec_id: localSpecId })) continue;
349
351
  if (text(mapId) && text(record.identity?.map_id) !== text(mapId)) continue;
350
352
  return record;
351
353
  }
@@ -376,7 +378,9 @@ function readPriorVerdictFile(path) {
376
378
  // verdicts are filed and matched under the names the record itself stores.
377
379
  function recordIdentityForDiscovery(record) {
378
380
  return {
379
- spec: { map_id: text(record?.identity?.map_id) || null },
381
+ // This is a read-side comparison, not an artifact writer. Preserve even an
382
+ // invalid local marker so discovery rejects it instead of using its Map ID.
383
+ spec: { map_id: text(record?.identity?.map_id) || null, local_spec_id: record?.identity?.local_spec_id ?? null },
380
384
  campaign: { public_route_slug: text(record?.identity?.campaign_slug) || null },
381
385
  };
382
386
  }
@@ -444,8 +448,8 @@ function locateExternalPriorVerdict({ targetRepo, record, ref }) {
444
448
  * The single-record boundary is unchanged: this still reads the final attempt
445
449
  * of exactly one earlier run, never a merged view across runs.
446
450
  */
447
- export function loadPriorQaVerdict({ baseDir, targetRepo = null, mapId = null, currentRunId = null } = {}) {
448
- const record = findPriorRunRecord({ baseDir, mapId, currentRunId });
451
+ export function loadPriorQaVerdict({ baseDir, targetRepo = null, mapId = null, localSpecId = null, currentRunId = null } = {}) {
452
+ const record = findPriorRunRecord({ baseDir, mapId, localSpecId, currentRunId });
449
453
  if (!record) return { verdict: null, record: null, path: null, reason: "no_prior_run" };
450
454
  const ref = (Array.isArray(record.artifacts) ? record.artifacts : []).findLast((artifact) => artifact?.kind === "qa_verdict");
451
455
  const refPath = text(ref?.path);
@@ -468,8 +472,8 @@ export function loadPriorQaVerdict({ baseDir, targetRepo = null, mapId = null, c
468
472
  * carries these, so doctor cause labels work against existing history with no
469
473
  * upgrade window. Returns `{ prior, record, reason }`.
470
474
  */
471
- function loadPriorDoctorFindings({ baseDir, mapId = null, currentRunId = null } = {}) {
472
- const record = findPriorRunRecord({ baseDir, mapId, currentRunId });
475
+ function loadPriorDoctorFindings({ baseDir, mapId = null, localSpecId = null, currentRunId = null } = {}) {
476
+ const record = findPriorRunRecord({ baseDir, mapId, localSpecId, currentRunId });
473
477
  if (!record) return { prior: null, record: null, reason: "no_prior_run" };
474
478
  const prior = priorSetFromRunRecordDoctor(record);
475
479
  if (!prior) return { prior: null, record, reason: "prior_run_without_doctor_observations" };
@@ -492,9 +496,9 @@ function loadPriorDoctorFindings({ baseDir, mapId = null, currentRunId = null }
492
496
  * raw map id) have no Run Record home, so they get `unknown` / `no_prior_run`
493
497
  * throughout — which is the truth, not a silence.
494
498
  */
495
- export function annotateQaAssertionCauses(assertions, { baseDir = null, targetRepo = null, mapId = null, currentRunId = null, isFinding } = {}) {
499
+ export function annotateQaAssertionCauses(assertions, { baseDir = null, targetRepo = null, mapId = null, localSpecId = null, currentRunId = null, isFinding } = {}) {
496
500
  const lookup = baseDir
497
- ? loadPriorQaVerdict({ baseDir, targetRepo, mapId, currentRunId })
501
+ ? loadPriorQaVerdict({ baseDir, targetRepo, mapId, localSpecId, currentRunId })
498
502
  : { verdict: null, record: null, path: null, reason: "no_prior_run" };
499
503
  const prior = lookup.verdict ? priorSetFromVerdict(lookup.verdict, { isFinding }) : null;
500
504
  const noPriorReason = lookup.reason || "no_prior_run";
@@ -528,9 +532,9 @@ const CAUSE_SUMMARY_SCHEMA = "campaigns-os-finding-cause/v0";
528
532
  * The doctor twin. `errors` and `warnings` are the doctor output's own arrays;
529
533
  * both are stamped in place. Returns the same summary shape.
530
534
  */
531
- export function annotateDoctorIssueCauses({ errors = [], warnings = [], baseDir = null, mapId = null, currentRunId = null } = {}) {
535
+ export function annotateDoctorIssueCauses({ errors = [], warnings = [], baseDir = null, mapId = null, localSpecId = null, currentRunId = null } = {}) {
532
536
  const lookup = baseDir
533
- ? loadPriorDoctorFindings({ baseDir, mapId, currentRunId })
537
+ ? loadPriorDoctorFindings({ baseDir, mapId, localSpecId, currentRunId })
534
538
  : { prior: null, record: null, reason: "no_prior_run" };
535
539
  const noPriorReason = lookup.reason || "no_prior_run";
536
540
  const findings = [];