@nextcommerce/campaigns-os 1.41.2 → 1.43.2

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 (83) hide show
  1. package/AGENTS.md +4 -2
  2. package/CHANGELOG.md +629 -0
  3. package/README.md +8 -6
  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 +5 -0
  7. package/contracts/effects.v1.json +118 -25
  8. package/contracts/migration-sidecar-bundle.v0.json +9 -0
  9. package/contracts/release-ledger.json +1424 -0
  10. package/contracts/supported-surface.json +12 -11
  11. package/docs/build-packet.md +120 -8
  12. package/docs/campaigns-os-build-flow.md +3 -2
  13. package/docs/design-source-package.md +89 -15
  14. package/docs/effects.md +83 -2
  15. package/docs/local-setup.md +51 -0
  16. package/docs/migration-sidecar-bundle.md +6 -1
  17. package/docs/orientation-contract-reference.md +1 -1
  18. package/docs/progress-snapshots.md +16 -6
  19. package/docs/qa-and-test-orders.md +157 -17
  20. package/docs/release-ledger-authoring-guide.md +6 -4
  21. package/docs/runtime-readiness.md +1 -1
  22. package/docs/skills-revision.md +10 -10
  23. package/package.json +3 -2
  24. package/schemas/campaign-runtime-assembly-report.v0.schema.json +6 -1
  25. package/schemas/campaign-runtime-build-packet.v0.schema.json +6 -1
  26. package/schemas/campaign-spec.v4.schema.json +4 -0
  27. package/schemas/campaigns-os-progress-snapshot.v0.schema.json +1 -0
  28. package/schemas/campaigns-os-qa-verdict-sidecar.v0.schema.json +1 -0
  29. package/schemas/campaigns-os-qa-verdict.v0.schema.json +1 -0
  30. package/schemas/campaigns-os-run-record.v0.schema.json +1 -0
  31. package/skills/campaign-lifecycle-orientation/SKILL.md +13 -8
  32. package/skills/campaign-readback-classification/SKILL.md +3 -3
  33. package/skills/campaign-run-evidence/SKILL.md +8 -6
  34. package/skills/contribution-intake/SKILL.md +3 -3
  35. package/skills/next-campaigns-build/SKILL.md +4 -4
  36. package/skills/next-campaigns-os/SKILL.md +17 -4
  37. package/skills/next-campaigns-os/references/session-intake.md +4 -4
  38. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  39. package/skills/next-campaigns-polish/SKILL.md +3 -3
  40. package/skills/next-campaigns-qa/SKILL.md +10 -9
  41. package/skills.json +11 -11
  42. package/src/build-brief.mjs +6 -4
  43. package/src/built-script-syntax.mjs +480 -0
  44. package/src/campaigns-api-key.mjs +99 -0
  45. package/src/cli-helpers.mjs +118 -0
  46. package/src/cli.mjs +796 -6963
  47. package/src/design-source-package.mjs +1 -1
  48. package/src/design-source-publication.mjs +898 -0
  49. package/src/diagnostic.mjs +2 -1
  50. package/src/directory-lock.mjs +270 -0
  51. package/src/doctor/checks.mjs +4415 -0
  52. package/src/doctor/inspect.mjs +636 -0
  53. package/src/doctor/next-step.mjs +731 -0
  54. package/src/finding-cause.mjs +14 -10
  55. package/src/install-invocation.mjs +29 -0
  56. package/src/invocation.mjs +179 -0
  57. package/src/lifecycle.mjs +5 -4
  58. package/src/polish-node.mjs +5 -2
  59. package/src/private-template-source.mjs +1 -1
  60. package/src/progress-node.mjs +9 -37
  61. package/src/progress.mjs +5 -3
  62. package/src/proof-policy.mjs +1 -1
  63. package/src/qa-analytics-correctness.mjs +3 -0
  64. package/src/qa-binding-evidence.mjs +76 -11
  65. package/src/qa-browser.mjs +778 -77
  66. package/src/qa-build-scope.mjs +47 -0
  67. package/src/qa-node.mjs +276 -39
  68. package/src/qa-publish.mjs +4 -0
  69. package/src/qa-sidecar.mjs +2 -0
  70. package/src/qa-verdict-discovery.mjs +11 -0
  71. package/src/qa-verdict-publish.mjs +1 -0
  72. package/src/qa-verdict.mjs +8 -1
  73. package/src/readback.mjs +2 -1
  74. package/src/run-record-closeout.mjs +3 -4
  75. package/src/run-record.mjs +4 -0
  76. package/src/sidecar-bundle.mjs +21 -0
  77. package/src/source-html-intake.mjs +1 -1
  78. package/src/source-html-manifest.mjs +9 -2
  79. package/src/spec-source-identity.mjs +44 -0
  80. package/src/stage-ledger.mjs +32 -1
  81. package/src/target-lock.mjs +54 -0
  82. package/src/template-brand-contract.mjs +17 -1
  83. package/src/tooling-setup.mjs +160 -0
@@ -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 = [];
@@ -0,0 +1,29 @@
1
+ // How this install spells the commands it prints. ROOT resolves `..` from this
2
+ // file, so the module must stay directly under src/ to name the package root.
3
+ import { dirname, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { invocationPrefixFor } from "./install-mode.mjs";
6
+
7
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
8
+
9
+ // Every command this CLI PRODUCES for an operator or agent to copy is spelled
10
+ // once, here, for the install it runs from (see install-mode.mjs): bare
11
+ // `campaigns-os` from a checkout, `npx --no-install campaigns-os` from a
12
+ // campaign folder that pins the toolkit, `npx --yes <spec>` from an npx cache.
13
+ // Result payloads are never rewritten after the fact — a path, a quoted
14
+ // argument or a data value that happens to contain the words is left exactly
15
+ // as it is.
16
+ function cmd(verb, rest = "") {
17
+ const prefix = invocationPrefixFor(ROOT);
18
+ return `${prefix} ${verb}${rest ? ` ${rest}` : ""}`;
19
+ }
20
+
21
+ // A registry command (gate actions, checkpoint remediations) is stored in its
22
+ // canonical bare form so internal bookkeeping can match on it; this spells
23
+ // it for the current install at the moment it is emitted.
24
+ function asInvocation(command) {
25
+ if (typeof command !== "string" || !command.startsWith("campaigns-os ")) return command;
26
+ return `${invocationPrefixFor(ROOT)} ${command.slice("campaigns-os ".length)}`;
27
+ }
28
+
29
+ export { ROOT, cmd, asInvocation };
@@ -0,0 +1,179 @@
1
+ // Invocation policy: which cross-cutting steps run around a command, and in
2
+ // what order. One owner for rules that used to be restated at each step — the
3
+ // pre-dispatch bypasses, the stale-session sweep and its root, the ambient
4
+ // run-session read, the lifecycle wrapper, the journal exemptions, the
5
+ // commands that implement --dry-run, and the QA auto-end trigger.
6
+ //
7
+ // The declaration below is data: a class, a sweep root kind and a few flags
8
+ // per command, and per subcommand only where the subcommand differs from its
9
+ // command. It describes kernel behaviour and is never a permission
10
+ // interpreter: contracts/effects.v1.json is bound to it by a test, never read
11
+ // here. The steps themselves (the sweep, the ambient read, the journal append,
12
+ // the auto-end, every handler) stay in cli.mjs and are handed to
13
+ // runInvocation() as functions; this module decides only whether and when each
14
+ // runs. It imports nothing from cli.mjs.
15
+
16
+ import { runWithRefusalScope, withCommandLifecycle } from "./lifecycle.mjs";
17
+
18
+ // Frozen all the way down, so a caller handed a subcommand list cannot edit it.
19
+ const frozen = (value) => { if (value && typeof value === "object") Object.values(value).forEach(frozen); return Object.freeze(value); };
20
+
21
+ // The steps each pre-dispatch class runs. `auth` and `inline` commands run
22
+ // their handler in place: no sweep, no ambient read, no wrapper, no journal.
23
+ // `inspection` still resolves the ambient session and is wrapped, but never
24
+ // sweeps or journals. `projection` (`readback`) is wrapped only: its --packet
25
+ // is an override naming the Build Packet to project, not a session locator.
26
+ // Read as one, the named file was loaded whole past readback's own size bound,
27
+ // and a valid override exited 1 whenever some active session was bound to a
28
+ // different packet; neither belongs to a command declared read-only.
29
+ const CLASS_STEPS = frozen({
30
+ auth: [], inline: [],
31
+ inspection: ["wrapper", "ambient"],
32
+ projection: ["wrapper"],
33
+ standard: ["wrapper", "ambient", "sweep", "journal"],
34
+ });
35
+
36
+ // Every top-level command, in the order did-you-mean breaks ties. Fields:
37
+ // `class` (default "standard"); `sweepRoot` ("target": the --target directory,
38
+ // "session": the run-session root; default none); `dryRun` (the command
39
+ // implements --dry-run); `journalExempt`; `journalExemptWhen` (exempt when the
40
+ // `given` flag is set and the `unlessBare` flag is not bare: an inspection must
41
+ // not append to a delivered campaign's active run);
42
+ // `subcommands` (the only subcommand names that resolve; others inherit the
43
+ // command's entry and are refused by the handler).
44
+ //
45
+ // `dryRun` marks only the commands that IMPLEMENT the flag. It reaches every
46
+ // handler through a permissive parseArgs, and an exemption scoped to the flag
47
+ // alone once fired on commands that ignore it: `qa run --dry-run` placed orders
48
+ // while writing no journal entry, and carried the flag into its own auto-end,
49
+ // which assembled no Run Record and left the session open. A command without
50
+ // `dryRun` given --dry-run journals if it otherwise would, and is not refused.
51
+ const COMMANDS = frozen({
52
+ help: { journalExempt: true },
53
+ login: { class: "auth" },
54
+ logout: { class: "auth" },
55
+ demo: { class: "inline" },
56
+ tooling: { subcommands: ["diagnose", "setup", "status"] },
57
+ sdk: { subcommands: ["storage-check"] },
58
+ readback: { class: "projection" },
59
+ start: { sweepRoot: "target" },
60
+ "prepare-build": { sweepRoot: "target" },
61
+ build: { sweepRoot: "target" },
62
+ doctor: { journalExemptWhen: { given: "packet", unlessBare: "write" } },
63
+ bundle: { subcommands: ["check"] },
64
+ standardize: {},
65
+ theme: { subcommands: ["generate", "inspect", "waive"] },
66
+ checkpoint: { subcommands: ["waive"] },
67
+ polish: { subcommands: ["capture"] },
68
+ "validate-assembly-report": {},
69
+ "install-agent-context": { dryRun: true },
70
+ "install-skills": { dryRun: true },
71
+ "page-kit": { subcommands: ["parity", "sync"] },
72
+ spec: { subcommands: ["derive"] },
73
+ next: { subcommands: ["build", "deploy", "polish", "qa", "setup"] },
74
+ qa: { subcommands: ["install-browser", "parity", "policy", "promote", "publish", "resolve", "run", "waive"] },
75
+ findings: { subcommands: ["add", "export", "harvest", "list"] },
76
+ "run-record": { dryRun: true },
77
+ telemetry: { subcommands: ["list", "off", "on", "status"] },
78
+ run: { subcommands: ["end", "start", "status"] },
79
+ });
80
+
81
+ // Where a subcommand's policy differs from its command's entry. Matched on the
82
+ // explicit subcommand token only: bare `run` defaults to `status` inside its
83
+ // handler, but is not `run status` here, so it journals.
84
+ const SUBCOMMAND_OVERRIDES = frozen({
85
+ "tooling diagnose": { class: "inline" },
86
+ "tooling setup": { class: "inline" },
87
+ "sdk storage-check": { class: "inspection" },
88
+ "theme waive": { dryRun: true },
89
+ "checkpoint waive": { dryRun: true },
90
+ "page-kit sync": { dryRun: true },
91
+ "spec derive": { dryRun: true },
92
+ "qa publish": { dryRun: true },
93
+ "qa run": { autoEnd: true },
94
+ "run start": { sweepRoot: "session" },
95
+ "run end": { sweepRoot: "session", dryRun: true },
96
+ "run status": { journalExempt: true },
97
+ });
98
+
99
+ export const commandNames = () => Object.keys(COMMANDS);
100
+
101
+ export const subcommandNames = (command) => (Object.hasOwn(COMMANDS, command) && COMMANDS[command].subcommands) || frozen([]);
102
+
103
+ // A run opted out of sessions altogether: no stale sweep, and no intake
104
+ // auto-start (which reads this predicate from here).
105
+ export const optsOutOfRunSession = (args) => args["no-run-session"] === true;
106
+
107
+ // The policy for one invocation. Two --dry-run predicates are deliberate and
108
+ // differ: on a command implementing the flag, its PRESENCE suppresses the
109
+ // sweep (a valued flag the handler will refuse still does nothing first), while
110
+ // only a bare `--dry-run` exempts the journal (the install commands accept a
111
+ // valued flag as a dry run and still journal it). The sweep writes a Run
112
+ // Record, deletes the session file and (under consent) remits — every effect
113
+ // --dry-run promises not to have — so the stale session stays stale until a
114
+ // real invocation closes it. --no-write suppresses both too: inheriting it
115
+ // into the closeout suppressed the Run Record but still deleted the session
116
+ // file. A refused invocation never journals either; that rule is per outcome,
117
+ // not per argv, and stays with the journal append.
118
+ export function resolveInvocationPolicy(command, args) {
119
+ const subcommand = subcommandNames(command).includes(args._[1]) ? args._[1] : null;
120
+ const rule = { class: "standard", ...(Object.hasOwn(COMMANDS, command) && COMMANDS[command]), ...(subcommand && SUBCOMMAND_OVERRIDES[`${command} ${subcommand}`]) };
121
+ const steps = CLASS_STEPS[rule.class];
122
+ const noWrite = args["no-write"] === true;
123
+ const implementsDryRun = rule.dryRun === true;
124
+ const sweepSuppressed = optsOutOfRunSession(args) || noWrite || (Object.hasOwn(args, "dry-run") && implementsDryRun);
125
+ const inspection = Boolean(rule.journalExemptWhen && args[rule.journalExemptWhen.given] && args[rule.journalExemptWhen.unlessBare] !== true);
126
+ return Object.freeze({
127
+ class: rule.class,
128
+ wrapper: steps.includes("wrapper"), ambient: steps.includes("ambient"),
129
+ sweepRoot: steps.includes("sweep") && !sweepSuppressed ? rule.sweepRoot || null : null,
130
+ journalExempt: !steps.includes("journal") || rule.journalExempt === true || noWrite || (args["dry-run"] === true && implementsDryRun) || inspection,
131
+ implementsDryRun, autoEnd: rule.autoEnd === true,
132
+ });
133
+ }
134
+
135
+ // The sequence main() delegates to. `steps` are cli.mjs mechanisms:
136
+ // dispatch(command, args, { recorder?, ambient?, sessionHolder? }?)
137
+ // closeOutStaleRunSessions(rootKind, args) -> the closed-out stale sessions
138
+ // ambientRunSession(args) -> the active session, or null
139
+ // lifecycleIdentity(args, ambient) -> { argvShape, runId }
140
+ // persistLifecycle(args, command, lifecycle, sessionHolder, thrown)
141
+ // autoEndAfterQa(args, sessionHolder, thrown, implementsDryRun)
142
+ // Order: normalise argv, open the refusal scope, run an unwrapped class in
143
+ // place, else sweep, read the ambient session, and wrap dispatch; on finish
144
+ // (success or error) journal unless exempt, then run the QA auto-end. The
145
+ // sweep precedes the ambient read so a stale session at the root is closed out
146
+ // (findRunSession ignores it) rather than abandoned with no Run Record. The
147
+ // wrapper re-throws unchanged, so the exit code is the command's own; a
148
+ // command that throws is recorded too. Persistence stays opt-in: with no
149
+ // --lifecycle-journal, CAMPAIGNS_OS_LIFECYCLE_LOG or active session nothing is
150
+ // written.
151
+ export async function runInvocation(args, steps) {
152
+ // `npx … campaigns-os <command>` hands the bin its own name as the first
153
+ // positional: it is the program name, not a command.
154
+ if (args._[0] === "campaigns-os") args._.shift();
155
+ const command = args._[0] || "help";
156
+ const policy = resolveInvocationPolicy(command, args);
157
+ // One refusal scope per invocation, so a refusal is visible only to this
158
+ // invocation's persistence step.
159
+ return runWithRefusalScope(async () => {
160
+ if (!policy.wrapper) return steps.dispatch(command, args);
161
+ const sweptStale = policy.sweepRoot ? await steps.closeOutStaleRunSessions(policy.sweepRoot, args) : [];
162
+ const ambient = policy.ambient ? steps.ambientRunSession(args) : null;
163
+ // Per invocation, never module state: an intake that opens or joins a run
164
+ // session mid-command publishes it here, so this command's own entry is
165
+ // persisted into it.
166
+ const sessionHolder = { current: ambient, autoStarted: false, adopted: false, qaResult: null, sweptStale };
167
+ await withCommandLifecycle(
168
+ {
169
+ command,
170
+ ...steps.lifecycleIdentity(args, ambient),
171
+ onFinish: async (lifecycle, thrown) => {
172
+ if (!policy.journalExempt) steps.persistLifecycle(args, command, lifecycle, sessionHolder, thrown);
173
+ if (policy.autoEnd) await steps.autoEndAfterQa(args, sessionHolder, thrown, policy.implementsDryRun);
174
+ },
175
+ },
176
+ (recorder) => steps.dispatch(command, args, { recorder, ambient, sessionHolder }),
177
+ );
178
+ });
179
+ }
package/src/lifecycle.mjs CHANGED
@@ -78,10 +78,11 @@ export function runWithRefusalScope(fn) {
78
78
  * the throw, because the paths that catch a refusal to render it (waiveOrRefuse)
79
79
  * hand onFinish no error to inspect. The cost of marking early is that a
80
80
  * `refused()` built inside a `try` that discards it would suppress the journal
81
- * entry for an invocation whose handler did run. No call site does that today
82
- * (no `requireArg` sits inside a `try`), and none may: if you need to probe
83
- * whether an argument is present, test for it — do not construct a refusal
84
- * speculatively.
81
+ * entry for an invocation whose handler did run. The optional QA progress
82
+ * probe checks for a packet before its swallowing `try`; closeRunSession runs
83
+ * its nested run-record attempt in a separate refusal scope. Keep those
84
+ * boundaries: if you need to probe an argument, test for it rather than
85
+ * constructing a refusal speculatively.
85
86
  */
86
87
  export function refused(message) {
87
88
  const store = refusalScope.getStore();
@@ -1,3 +1,4 @@
1
+ import { campaignIdentitiesMatch, localSpecIdentityFields } from "./spec-source-identity.mjs";
1
2
  import { createHash } from "node:crypto";
2
3
  import { HIDDEN_EAGER_MEDIA_ACTIONS } from "./gate-actions.mjs";
3
4
  import { dirname, join, resolve } from "node:path";
@@ -330,8 +331,8 @@ export function createPolishCaptureBinding({ packet, report, plan, packetPath, t
330
331
  const slug = captureCampaignSlug(packet, report);
331
332
  const packetMapId = nonemptyString(packet?.spec?.map_id);
332
333
  const reportMapId = nonemptyString(report?.identity?.map_id);
333
- if (!packetMapId || packetMapId !== reportMapId) {
334
- throw new Error("polish capture requires matching packet and Assembly Report map identities.");
334
+ if (!campaignIdentitiesMatch(packet?.spec, report?.identity)) {
335
+ throw new Error("polish capture requires matching packet and Assembly Report campaign identities.");
335
336
  }
336
337
  const buildFingerprint = currentBuildFingerprint(report);
337
338
  if (!buildFingerprint) throw new Error("polish capture requires a strict current Assembly Report build fingerprint.");
@@ -360,6 +361,7 @@ export function createPolishCaptureBinding({ packet, report, plan, packetPath, t
360
361
  resolved_path: resolvedPacketPath,
361
362
  resolved_target_repo: resolvedTargetRepo,
362
363
  map_id: packetMapId,
364
+ ...localSpecIdentityFields(packet.spec),
363
365
  campaign_slug: slug,
364
366
  route_root: nonemptyString(packet?.campaign?.route_root),
365
367
  target_repo: nonemptyString(packet?.assembly?.target_repo),
@@ -368,6 +370,7 @@ export function createPolishCaptureBinding({ packet, report, plan, packetPath, t
368
370
  run_id: runId,
369
371
  identity: {
370
372
  map_id: reportMapId,
373
+ ...localSpecIdentityFields(report.identity),
371
374
  public_route_slug: nonemptyString(report?.identity?.public_route_slug),
372
375
  spec_hash: nonemptyString(report?.identity?.spec_hash),
373
376
  },
@@ -83,7 +83,7 @@ function privateTemplateSourcesPath() {
83
83
  }
84
84
 
85
85
  // No caching, recomputed per call — matches certifiedTemplateFamilies()'s
86
- // existing convention (cli.mjs) so a long-lived process never serves a stale
86
+ // existing convention (src/doctor/checks.mjs) so a long-lived process never serves a stale
87
87
  // allowlist after an edit.
88
88
  export function loadPrivateTemplateSources() {
89
89
  const path = privateTemplateSourcesPath();
@@ -1,10 +1,12 @@
1
+ import { campaignSpecIdentity, campaignIdentitiesMatch, localSpecIdentityFields } from "./spec-source-identity.mjs";
1
2
  // Best-effort producer adapter. Sanitized immutable bytes are durable before delivery.
2
3
  import {randomBytes,createHash} from 'node:crypto';
3
- import {mkdirSync,readFileSync,writeFileSync,renameSync,rmSync,readdirSync,lstatSync} from 'node:fs';
4
+ import {mkdirSync,readFileSync,writeFileSync,renameSync,rmSync,readdirSync} from 'node:fs';
4
5
  import {join,resolve,dirname} from 'node:path';
5
6
  import {PROGRESS_SCHEMA_VERSION,PROGRESS_STAGES,PROGRESS_STAGE_STATUSES,PROGRESS_CONTINUATIONS,PROGRESS_ACTION_IDS,PROGRESS_GATE_IDS,canonicalProgressJson,progressSnapshotId,verifyProgressSnapshot} from './progress.mjs';
6
7
  import {specMaterialHash} from './spec-identity.mjs';
7
8
  import {sameFile} from './fs-identity.mjs';
9
+ import {withDirectoryLock} from './directory-lock.mjs';
8
10
  import {resolveConsent,CANONICAL_REMIT_SCOPE,normalizeConsentScope,announceDefaultOnTelemetry} from './consent.mjs';
9
11
  import {boundedResponseText,isLoopbackHostname} from './remit.mjs';
10
12
  export const PROGRESS_OBSERVATION = Symbol('canonical progress observation');
@@ -46,7 +48,7 @@ export function projectProgressObservation({workspace,context,report,doctor,cont
46
48
  const aligned=contextBound&&mapId&&localMapId===mapId&&baseline?.map_id===mapId&&baseline?.algorithm==='map-store-v1'&&hash(baseline.hash)&&localHash&&localHash===hash(baseline.local_spec_material_hash);
47
49
  const build=hash(doctor?.derived?.build_output_fingerprint?.value);
48
50
  const recordedBuild=hash(report?.stages?.assembly?.build_fingerprint);
49
- const reportBound=doctor?.derived?.prepare_build_gate?.binding_failure!==true&&contextBound&&mapId&&localMapId===mapId&&report?.identity?.map_id===mapId&&localHash&&localHash===hash(report?.identity?.spec_material_hash);
51
+ const reportBound=doctor?.derived?.prepare_build_gate?.binding_failure!==true&&contextBound&&campaignIdentitiesMatch(packet?.spec,campaignSpecIdentity(spec))&&campaignIdentitiesMatch(packet?.spec,report?.identity)&&localHash&&localHash===hash(report?.identity?.spec_material_hash);
50
52
  const qaSource=hash(report?.stages?.qa?.evidence?.source_build_fingerprint);
51
53
  const verdict=qaResult?.verdict;
52
54
  const qa=verdict?{
@@ -60,7 +62,7 @@ export function projectProgressObservation({workspace,context,report,doctor,cont
60
62
  const preview=typeof packet?.deploy?.preview_url==='string'&&packet.deploy.preview_url?packet.deploy.preview_url:null;
61
63
  return {
62
64
  schema_version:PROGRESS_SCHEMA_VERSION,package_version:packageVersion,producer:qaResult?'qa':'next',
63
- identity:{map_id:mapId,map_revision_hash:contextBound&&baseline?.map_id===mapId?hash(baseline?.hash):(localMapId===mapId?hash(spec?.spec_identity?.spec_hash||spec?.spec_hash):null),map_revision_algorithm:'map-store-v1',saved_revision_alignment:aligned?'aligned':'unconfirmed',local_spec_material_hash:localHash,local_spec_material_algorithm:'campaign-spec-material-v1',build_fingerprint:build,build_fingerprint_algorithm:'sha256-manifest/v1'},
65
+ identity:{map_id:mapId,...localSpecIdentityFields(packet?.spec),map_revision_hash:mapId?(contextBound&&baseline?.map_id===mapId?hash(baseline?.hash):(localMapId===mapId?hash(spec?.spec_identity?.spec_hash||spec?.spec_hash):null)):null,map_revision_algorithm:'map-store-v1',saved_revision_alignment:aligned?'aligned':'unconfirmed',local_spec_material_hash:localHash,local_spec_material_algorithm:'campaign-spec-material-v1',build_fingerprint:build,build_fingerprint_algorithm:'sha256-manifest/v1'},
64
66
  stages:PROGRESS_STAGES.map(stage=>{
65
67
  const status=reportBound?accepted(report?.stages?.[stage]?.status,PROGRESS_STAGE_STATUSES):'unknown';
66
68
  const source=stage==='assembly'?recordedBuild:stage==='qa'?qaSource:null;
@@ -70,39 +72,9 @@ export function projectProgressObservation({workspace,context,report,doctor,cont
70
72
  continuation:{stage,blocked:continuation?.ok!==true||(Array.isArray(continuation?.divergences)&&continuation.divergences.length>0)||stage==='unknown'||gates.some(gate=>['blocked','unknown'].includes(gate.state))||actions.includes('unknown'),divergent:Array.isArray(continuation?.divergences)&&continuation.divergences.length>0,action_ids:actions,gates},qa,
71
73
  };
72
74
  }
73
- async function lock(dir,fn,{budgetMs=1500}={}) {
74
- const path=join(dir,'.allocation-lock'); const start=Date.now();const token=randomBytes(16).toString('hex');
75
- const abandoned=(unownedMtime=null)=>{
76
- try {
77
- const stat=lstatSync(path);if(!stat.isDirectory()||stat.isSymbolicLink())return false;
78
- const owner=read(join(path,'owner.json'));
79
- if(Number.isInteger(owner?.pid)&&owner.pid>0&&typeof owner.token==='string') {
80
- try {process.kill(owner.pid,0);return false;}catch(error){return error.code==='ESRCH';}
81
- }
82
- // A killed process can leave the directory before writing its owner.
83
- // Give a live allocator ample time to finish that tiny synchronous gap.
84
- return Date.now()-(unownedMtime??stat.mtimeMs)>10000;
85
- }catch{return false;}
86
- };
87
- const recover=()=>{
88
- if(!abandoned())return;
89
- let originalMtime;try{originalMtime=lstatSync(path).mtimeMs;}catch{return;}
90
- const claim=join(path,'.recovery');
91
- // Only this exclusive claimant can rename the old lock. An interrupted
92
- // recovery claim fails closed for explicit offline recovery; recursively
93
- // stealing recovery claims would reintroduce a check/rename race.
94
- try {mkdirSync(claim);atomic(join(claim,'owner.json'),{pid:process.pid,token});}catch{return;}
95
- let moved=false;
96
- try {
97
- if(!abandoned(originalMtime))return;
98
- const tomb=`${path}.abandoned-${token}`;
99
- renameSync(path,tomb);moved=true;rmSync(tomb,{recursive:true,force:true});
100
- }catch{}finally{if(!moved&&read(join(claim,'owner.json'))?.token===token){try{rmSync(claim,{recursive:true,force:true});}catch{}}}
101
- };
102
- while (true) {
103
- try {mkdirSync(path);atomic(join(path,'owner.json'),{pid:process.pid,token});break;}catch(error){if(error.code!=='EEXIST'||Date.now()-start>=budgetMs)throw new Error('progress.lock_unavailable');recover();await new Promise(resolve=>setTimeout(resolve,20));}
104
- }
105
- try{return await fn();}finally{if(read(join(path,'owner.json'))?.token===token)rmSync(path,{recursive:true,force:true});}
75
+ function lock(dir,fn,{budgetMs=1500}={}) {
76
+ const lockPath=join(dir,'.allocation-lock');
77
+ return withDirectoryLock(lockPath,fn,{budgetMs,unavailable:()=>Object.assign(new Error('progress.lock_unavailable'),{lockPath})});
106
78
  }
107
79
  export async function persistProgressObservation(observation,{dir,now=()=>new Date(),historyLimit=32}={}) {
108
80
  mkdirSync(dir,{recursive:true,mode:0o700});
@@ -170,7 +142,7 @@ export async function observeProgress(args,continuation,{qaResult=null,packageVe
170
142
  if(remit.state==='failed')warn('[campaigns-os] Progress delivery pending; the local observation is retained. Lifecycle result is unchanged.');
171
143
  return {...remit,snapshot_id:snapshot.snapshot_id,reused};
172
144
  } catch(error) {
173
- if(error?.message==='progress.lock_unavailable')warn('[campaigns-os] Progress allocation lock occupied; wait for the current writer. For abandoned or interrupted recovery, stop target writers and follow the offline lock recovery in docs/progress-snapshots.md. Lifecycle result is unchanged.');
145
+ if(error?.message==='progress.lock_unavailable')warn(`[campaigns-os] Progress allocation lock occupied${error.lockPath?` at ${error.lockPath}`:''}; wait for the current writer. If it stays occupied (an abandoned, ownerless or interrupted lock), stop all campaigns-os writers for this target, then remove that lock directory (docs/progress-snapshots.md). Lifecycle result is unchanged.`);
174
146
  else warn('[campaigns-os] Progress observation unavailable; lifecycle result is unchanged.');
175
147
  return {state:'failed',reason:'capture_unavailable'};
176
148
  }
package/src/progress.mjs CHANGED
@@ -22,7 +22,7 @@ const str = pattern => ({type:'string', pattern});
22
22
  const hash = {type:['string','null'], pattern:'^sha256:[0-9a-f]{64}$'};
23
23
  const opaque = {type:['string','null'], pattern:'^[A-Za-z0-9_-]{1,64}$'};
24
24
  const enumeration = values => ({enum:values});
25
- const object = properties => ({type:'object', additionalProperties:false, required:Object.keys(properties), properties});
25
+ const object = (properties, optional=[]) => ({type:'object', additionalProperties:false, required:Object.keys(properties).filter(key=>!optional.includes(key)), properties});
26
26
  export const PROGRESS_SNAPSHOT_SCHEMA = {
27
27
  $schema:'https://json-schema.org/draft/2020-12/schema',
28
28
  $id:'https://nextcommerce.com/schemas/campaigns-os-progress-snapshot.v0.schema.json',
@@ -32,10 +32,10 @@ export const PROGRESS_SNAPSHOT_SCHEMA = {
32
32
  stream_id:str('^progress_[0-9a-f]{32}$'), sequence:{type:'integer',minimum:1,maximum:2147483647},
33
33
  previous_snapshot_id:hash, observed_at:str('^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$'),
34
34
  package_version:str('^\\d{1,6}\\.\\d{1,6}\\.\\d{1,6}$'), producer:enumeration(['next','qa']),
35
- identity:object({map_id:opaque,map_revision_hash:hash,map_revision_algorithm:{const:'map-store-v1'},
35
+ identity:object({map_id:opaque,local_spec_id:str('^[A-Za-z0-9_-]{1,64}$'),map_revision_hash:hash,map_revision_algorithm:{const:'map-store-v1'},
36
36
  saved_revision_alignment:enumeration(['aligned','unconfirmed']),local_spec_material_hash:hash,
37
37
  local_spec_material_algorithm:{const:'campaign-spec-material-v1'},build_fingerprint:hash,
38
- build_fingerprint_algorithm:{const:'sha256-manifest/v1'}}),
38
+ build_fingerprint_algorithm:{const:'sha256-manifest/v1'}},['local_spec_id']),
39
39
  stages:{type:'array',minItems:6,maxItems:6,items:object({stage:enumeration(PROGRESS_STAGES),status:enumeration(PROGRESS_STAGE_STATUSES),
40
40
  build_binding:enumeration(['matching','unconfirmed']),source_build_fingerprint:hash})},
41
41
  preview:object({present:{type:'boolean'},url_hash:hash}),
@@ -46,6 +46,7 @@ export const PROGRESS_SNAPSHOT_SCHEMA = {
46
46
  binding:enumeration(['matching','unconfirmed']),publish_state:enumeration(['skipped','ok','failed','unknown'])})]},
47
47
  }),
48
48
  };
49
+
49
50
  export function canonicalProgressJson(value) {
50
51
  if (Array.isArray(value)) return `[${value.map(canonicalProgressJson).join(',')}]`;
51
52
  if (value && typeof value === 'object') return `{${Object.keys(value).sort().map(key=>`${JSON.stringify(key)}:${canonicalProgressJson(value[key])}`).join(',')}}`;
@@ -92,6 +93,7 @@ export function validateProgressSnapshot(value) {
92
93
  }
93
94
  if (!check(value,PROGRESS_SNAPSHOT_SCHEMA)) errors.push('progress.invalid_shape');
94
95
  if (!errors.length && (new Set(value.stages.map(stage=>stage.stage)).size!==6 || value.stages.some((stage,index)=>stage.stage!==PROGRESS_STAGES[index]))) errors.push('progress.invalid_stages');
96
+ if (!errors.length && value.identity.local_spec_id && (value.identity.map_id || value.identity.map_revision_hash || value.identity.saved_revision_alignment !== 'unconfirmed')) errors.push('progress.invalid_local_identity');
95
97
  if (!errors.length && value.identity.saved_revision_alignment==='aligned' && (!value.identity.map_id||!value.identity.map_revision_hash||!value.identity.local_spec_material_hash)) errors.push('progress.invalid_alignment');
96
98
  if (!errors.length && value.continuation.gates.some(gate=>gate.id==='unknown'&&gate.state!=='unknown')) errors.push('progress.unsupported_authority');
97
99
  if (!errors.length && value.sequence===1 && value.previous_snapshot_id!==null) errors.push('progress.invalid_chain');
@@ -4,7 +4,7 @@
4
4
  //
5
5
  // `qa.proof_policy.order_path_depth` is seeded by prepare-build/start and
6
6
  // mirrored into `report.proof_policy` at the same moment. The two are compared
7
- // by `assessPurchaseProofCoverage` (cli.mjs): a disagreement is `unknown`,
7
+ // by `assessPurchaseProofCoverage` (src/doctor/next-step.mjs): a disagreement is `unknown`,
8
8
  // never one side's value. Doctor, `next` and the coverage reason all describe
9
9
  // that state through the single action below, so the command an operator is
10
10
  // told to run is spelled once. A leaf: gate-actions only.
@@ -228,6 +228,9 @@ export function assessReceiptPurchase(receiptAnalytics = {}, options = {}) {
228
228
  receipts.push({
229
229
  plan_id: planId,
230
230
  receipt_url: redactUrlQuery(attempt.receiptUrl),
231
+ // The settled document location the receipt capture was read from
232
+ // (#500); null when it was not recorded.
233
+ receipt_document_url: redactUrlQuery(attempt.receiptDocumentUrl) || null,
231
234
  measured,
232
235
  scope: measured ? scope : null,
233
236
  purchase_fired: measured && !!effective.fired,