@nextcommerce/campaigns-os 1.59.0 → 1.61.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +242 -0
- package/README.md +11 -10
- package/agents/claude/CLAUDE.md +15 -0
- package/agents/codex/AGENTS.md +15 -0
- package/agents/copilot/copilot-instructions.md +15 -0
- package/agents/cursor/campaigns-os.mdc +15 -0
- package/compatibility.json +1 -1
- package/contracts/brand-theme-target-tokens.next-core.v0.json +95 -25
- package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
- package/contracts/commerce-surface-catalog.json +27 -26
- package/contracts/release-ledger.json +1311 -0
- package/contracts/supported-surface.json +4 -4
- package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
- package/docs/access-model.md +20 -0
- package/docs/brand-theme-bridge.md +62 -3
- package/docs/build-packet.md +42 -13
- package/docs/campaign-build-brief.md +17 -1
- package/docs/campaigns-os-build-flow.md +62 -3
- package/docs/campaignspec-authoring-examples.md +116 -0
- package/docs/design-source-package.md +46 -2
- package/docs/entry-points.md +188 -0
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/polish-evidence.md +41 -3
- package/docs/qa-and-test-orders.md +50 -2
- package/docs/quickstart.md +438 -0
- package/docs/readback.md +119 -6
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +3 -1
- package/docs/skills-revision.md +10 -10
- package/docs/source-adapters.md +213 -0
- package/package.json +10 -3
- package/schemas/campaign-design-source-package.v0.schema.json +8 -0
- package/schemas/campaigns-os-readback.v2.schema.json +177 -1
- package/schemas/source-html-manifest.v0.schema.json +3 -0
- package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +3 -3
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +5 -5
- package/skills/next-campaigns-os/SKILL.md +4 -3
- package/skills/next-campaigns-os-setup/SKILL.md +3 -3
- package/skills/next-campaigns-polish/SKILL.md +12 -16
- package/skills/next-campaigns-qa/SKILL.md +3 -3
- package/skills/next-campaigns-sdk-bump/SKILL.md +286 -0
- package/skills.json +18 -10
- package/src/brand-theme.mjs +413 -8
- package/src/brief-summary.mjs +6 -2
- package/src/browser-launch.mjs +2 -1
- package/src/build-brief.mjs +9 -2
- package/src/built-site-scope.mjs +7 -14
- package/src/campaign-identity.mjs +6 -4
- package/src/cli.mjs +89 -73
- package/src/design-file-reference.mjs +93 -0
- package/src/design-source-package.mjs +55 -1
- package/src/doctor/checks.mjs +14 -77
- package/src/doctor/ci.mjs +112 -0
- package/src/doctor/inspect.mjs +20 -8
- package/src/doctor/next-step.mjs +6 -3
- package/src/doctor/source-provenance.mjs +74 -0
- package/src/evidence-debt.mjs +119 -0
- package/src/orchestration-stage-contract.mjs +6 -3
- package/src/page-kit-build-summary.mjs +21 -1
- package/src/page-kit-campaign-config.mjs +19 -0
- package/src/parity/compare.mjs +316 -0
- package/src/parity/design-access.mjs +54 -0
- package/src/parity/element-parity-record.mjs +569 -0
- package/src/parity/probe.mjs +205 -0
- package/src/parity/snapshot.mjs +110 -0
- package/src/png-header.mjs +87 -0
- package/src/polish-browser.mjs +186 -9
- package/src/polish-captures-record.mjs +52 -0
- package/src/polish-deadline.mjs +14 -0
- package/src/polish-gate.mjs +26 -11
- package/src/polish-node.mjs +81 -7
- package/src/polish-visual-fixtures.mjs +74 -0
- package/src/polish-visual.mjs +606 -0
- package/src/progress-node.mjs +2 -1
- package/src/qa-analytics-correctness.mjs +14 -2
- package/src/qa-analytics-parity.mjs +94 -5
- package/src/qa-node.mjs +39 -6
- package/src/qc-accept.mjs +148 -6
- package/src/qc-check-registry.mjs +37 -1
- package/src/qc-results.mjs +404 -16
- package/src/readback.mjs +57 -13
- package/src/sdk-attribute-index.mjs +11 -11
- package/src/sdk-markup.mjs +77 -2
- package/src/sdk-storage-compatibility.mjs +94 -4
- package/src/source-html-manifest.mjs +7 -0
- package/src/source-html-server.mjs +129 -0
- package/src/spec-derive.mjs +24 -9
- package/src/stage-ledger.mjs +27 -1
- package/src/stage-record.mjs +63 -9
- package/src/theme-gate.mjs +2 -2
- package/src/visual-diff.mjs +296 -0
- package/src/visual-reference.mjs +126 -0
package/src/built-site-scope.mjs
CHANGED
|
@@ -96,14 +96,14 @@ function listHtmlFiles(root, links = null) {
|
|
|
96
96
|
return files.sort();
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
function routeForFile(campaignDir, file) {
|
|
99
|
+
export function routeForFile(campaignDir, file) {
|
|
100
100
|
const rel = relative(campaignDir, file).split(sep).join("/");
|
|
101
101
|
if (rel === "index.html") return "";
|
|
102
102
|
if (rel.endsWith("/index.html")) return rel.slice(0, -"/index.html".length);
|
|
103
103
|
return rel.replace(/\.html$/i, "");
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
function pageIdForRoute(route) {
|
|
106
|
+
export function pageIdForRoute(route) {
|
|
107
107
|
if (!route) return "index";
|
|
108
108
|
return route.replace(/\//g, "-").replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "index";
|
|
109
109
|
}
|
|
@@ -290,9 +290,8 @@ export function synthesizeMinimalBuildPacket({
|
|
|
290
290
|
// the same build. The manifest is `<path>\n<sha256>\n` per file, paths
|
|
291
291
|
// relative to the output root with `/` separators and sorted by code point,
|
|
292
292
|
// so directory walk order and the absolute location never leak into the
|
|
293
|
-
// value. Page Kit (0.2.0) writes only rendered HTML and copied
|
|
294
|
-
// _site/, nothing it timestamps, so
|
|
295
|
-
// takes root-relative paths for a consumer whose build does stamp a file.
|
|
293
|
+
// value. Page Kit (0.2.x and 0.3.x) writes only rendered HTML and copied
|
|
294
|
+
// assets into _site/, nothing it timestamps, so every file is hashed.
|
|
296
295
|
// Symbolic links are never build output and are skipped, not followed.
|
|
297
296
|
export const BUILD_FINGERPRINT_ALGORITHM = "sha256-manifest/v1";
|
|
298
297
|
|
|
@@ -326,22 +325,17 @@ function sha256Hex(buffer) {
|
|
|
326
325
|
* hand (`sha256sum` over each file, sort by path, hash the lines). `ok: false`
|
|
327
326
|
* with `error` when the root is not a directory; an empty tree still hashes
|
|
328
327
|
* (the empty manifest) so a wiped _site/ reads as a changed build, not a
|
|
329
|
-
* missing one.
|
|
328
|
+
* missing one. `excluded` is always empty; doctor's build_output_fingerprint
|
|
329
|
+
* still reports it.
|
|
330
330
|
*/
|
|
331
|
-
export function computeBuildFingerprint(outputDir
|
|
331
|
+
export function computeBuildFingerprint(outputDir) {
|
|
332
332
|
const root = String(outputDir || "");
|
|
333
333
|
const base = { ok: false, algorithm: BUILD_FINGERPRINT_ALGORITHM, root, fingerprint: null, file_count: 0, excluded: [], manifest: null };
|
|
334
334
|
if (!root || !existsSync(root) || !statSync(root).isDirectory()) {
|
|
335
335
|
return { ...base, error: `Build output directory does not exist: ${root || "(empty)"}` };
|
|
336
336
|
}
|
|
337
|
-
const excludeSet = new Set((Array.isArray(exclude) ? exclude : []).map((path) => String(path).split(sep).join("/")));
|
|
338
|
-
const excluded = [];
|
|
339
337
|
const lines = [];
|
|
340
338
|
for (const path of listFilesRelative(root)) {
|
|
341
|
-
if (excludeSet.has(path)) {
|
|
342
|
-
excluded.push(path);
|
|
343
|
-
continue;
|
|
344
|
-
}
|
|
345
339
|
lines.push(`${path}\n${sha256Hex(readFileSync(join(root, path)))}\n`);
|
|
346
340
|
}
|
|
347
341
|
const manifest = lines.join("");
|
|
@@ -350,7 +344,6 @@ export function computeBuildFingerprint(outputDir, { exclude = [] } = {}) {
|
|
|
350
344
|
ok: true,
|
|
351
345
|
fingerprint: `sha256:${sha256Hex(Buffer.from(manifest, "utf8"))}`,
|
|
352
346
|
file_count: lines.length,
|
|
353
|
-
excluded,
|
|
354
347
|
manifest,
|
|
355
348
|
error: null,
|
|
356
349
|
};
|
|
@@ -263,9 +263,11 @@ function gateBase(subject) {
|
|
|
263
263
|
* A page marked `spec_page: false` is built output that no CampaignSpec page
|
|
264
264
|
* builds to. It is still scanned, because it is still served, but a finding
|
|
265
265
|
* that names it says so: such a file is usually left in `_site/` by an earlier
|
|
266
|
-
* build, or built from an HTML file copied into the source
|
|
267
|
-
*
|
|
268
|
-
*
|
|
266
|
+
* build, or built from an HTML file copied into the source, and the repair is
|
|
267
|
+
* to remove it, not to retag it. Both are page-kit 0.2.x behaviour (no
|
|
268
|
+
* `_site/` pruning; a design export's `index.html` under `assets/` builds as
|
|
269
|
+
* a page). 0.3.0 cleans `_site/<slug>/` and ignores `.html` under `assets/`,
|
|
270
|
+
* so there a stray is a source page no spec page claims.
|
|
269
271
|
*
|
|
270
272
|
* @param {{ subject: object, pages: Array<{ page_id: string, route?: string|null,
|
|
271
273
|
* file?: string|null, content: string, spec_page?: boolean,
|
|
@@ -415,7 +417,7 @@ export function evaluateCampaignIdentity({ subject, pages = [], expectedApiKey =
|
|
|
415
417
|
// own instruction (retag, or add a missing tag to a real page) is dropped:
|
|
416
418
|
// that edit would land on the wrong file.
|
|
417
419
|
const named = stray.length > 2 ? `${stray.slice(0, -1).join(", ")}, and ${stray.at(-1)}` : stray.join(" and ");
|
|
418
|
-
finding.message = `${named} ${stray.length === 1 ? "is" : "are"} not the built page of any CampaignSpec page: built output left by an earlier build, or an HTML file copied into the source (a design export's index.html under assets/ builds as its own page). Remove the source file if there is one, delete the built file, then rebuild and record the build again; do not retag either page. What it causes: ${finding.evidence}`;
|
|
420
|
+
finding.message = `${named} ${stray.length === 1 ? "is" : "are"} not the built page of any CampaignSpec page: built output left by an earlier build, or an HTML file copied into the source (on page-kit 0.2.x a design export's index.html under assets/ builds as its own page). Remove the source file if there is one, delete the built file, then rebuild and record the build again; do not retag either page. What it causes: ${finding.evidence}`;
|
|
419
421
|
}
|
|
420
422
|
|
|
421
423
|
// evidence is the message's first sentence, kept apart only so a stray
|
package/src/cli.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { campaignSpecIdentity, resolveCampaignIdentity, campaignIdentitiesMatch, localSpecIdentityFields } from "./spec-source-identity.mjs";
|
|
2
|
+
import { withDoctorCiReport } from "./doctor/ci.mjs";
|
|
2
3
|
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { resolveDesignFileReferences } from "./design-file-reference.mjs";
|
|
3
5
|
import { withDesignSourcePublication } from "./design-source-publication.mjs";
|
|
4
6
|
import { createDemo, demoArguments } from "./demo.mjs";
|
|
5
7
|
import { execFileSync } from "node:child_process";
|
|
@@ -193,13 +195,9 @@ import {
|
|
|
193
195
|
import {
|
|
194
196
|
ASSEMBLY_REPORT_STAGE_KEYS,
|
|
195
197
|
NEXT_STAGE_ORDER,
|
|
196
|
-
STAGE_TERMINAL_STATUS_PREFIXES,
|
|
197
198
|
stageIsTerminal,
|
|
198
199
|
} from "./orchestration-stage-contract.mjs";
|
|
199
|
-
import {
|
|
200
|
-
currentBuildFingerprint,
|
|
201
|
-
evaluatePolishGate,
|
|
202
|
-
} from "./polish-gate.mjs";
|
|
200
|
+
import { currentBuildFingerprint } from "./polish-gate.mjs";
|
|
203
201
|
import {
|
|
204
202
|
assertPolishCaptureBindingUnchanged,
|
|
205
203
|
capturePolishPageLoad,
|
|
@@ -221,6 +219,7 @@ import {
|
|
|
221
219
|
import {
|
|
222
220
|
loadPageKitCampaignEntry,
|
|
223
221
|
PAGE_KIT_CAMPAIGNS_REL_PATH,
|
|
222
|
+
pageKitIgnoresAssetsHtml,
|
|
224
223
|
} from "./page-kit-campaign-config.mjs";
|
|
225
224
|
import {
|
|
226
225
|
evaluatePageKitStoreProfile,
|
|
@@ -240,6 +239,7 @@ import {
|
|
|
240
239
|
applySpecDerive,
|
|
241
240
|
formatDeriveValue,
|
|
242
241
|
isPageTreeIgnoredDir,
|
|
242
|
+
isPageTreeIgnoredPath,
|
|
243
243
|
frontmatterPermalink,
|
|
244
244
|
pageRouteForFile,
|
|
245
245
|
permalinkRoute,
|
|
@@ -293,7 +293,7 @@ import {
|
|
|
293
293
|
addPrepareBuildGateErrors,
|
|
294
294
|
assessPurchaseProofCoverage,
|
|
295
295
|
deployUrlFromReportOutputs,
|
|
296
|
-
|
|
296
|
+
readinessStatus,
|
|
297
297
|
} from "./doctor/next-step.mjs";
|
|
298
298
|
|
|
299
299
|
export { derivePackagePin, localInstallStatus };
|
|
@@ -322,7 +322,7 @@ Usage:
|
|
|
322
322
|
[--wrapper-policy <strip_document_wrappers|preserve_document_wrappers|not_required|unknown>] [--design-manifest <path>]
|
|
323
323
|
[--allow-uncertified-template "<reason>"] [--order-path-depth <off|common|full>]
|
|
324
324
|
[--deploy-target <target>] [--preview-url <url>] [--production-url <url>] [--no-run-session] [--force] # intake alias for prepare-build + doctor
|
|
325
|
-
campaigns-os doctor --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--strip-paths] [--write] [--no-write] [--doctor-out <path>] [--proxy-base <url>] [--no-live-refs] [--json] # inspection by default; --doctor-out requires --write; --no-write wins. When the packet's built _site/<route>/ exists and a public Campaigns API key resolves (packet, its local CampaignSpec, or the declared campaign-key env var), doctor makes one read-only GET of {proxy-base}/api/campaign under X-Campaign-Key to check each built page's shipping and package refs against the live campaign; --proxy-base overrides the canonical proxy (https, or a loopback host over http). --no-live-refs skips the read and records not_run with reason disabled. No key, no built page, or a failed read records derived.live_campaign_refs as not_run with its reason. Only doctor and qa run make this read; other commands that run doctor record not_read
|
|
325
|
+
campaigns-os doctor --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--strip-paths] [--write] [--no-write] [--doctor-out <path>] [--proxy-base <url>] [--no-live-refs] [--ci] [--json] # inspection by default; --doctor-out requires --write; --no-write wins. --ci: Markdown (or JSON ci object), exits 0 current / 3 owed / 4 unknown / 2 packet usage error; other doctor errors do not affect the CI exit. Without --ci: 0 ok / 2 errors. When the packet's built _site/<route>/ exists and a public Campaigns API key resolves (packet, its local CampaignSpec, or the declared campaign-key env var), doctor makes one read-only GET of {proxy-base}/api/campaign under X-Campaign-Key to check each built page's shipping and package refs against the live campaign; --proxy-base overrides the canonical proxy (https, or a loopback host over http). --no-live-refs skips the read and records not_run with reason disabled. No key, no built page, or a failed read records derived.live_campaign_refs as not_run with its reason. Only doctor and qa run make this read; other commands that run doctor record not_read
|
|
326
326
|
campaigns-os doctor --built <page-kit-target-repo> --family <family> [--slug <slug>] [--base-url <url>] [--emit-packet [path]] [--json] # L7: doctor a built _site/ with no Build Packet. Reads the local files only: --base-url is not fetched, it fills deploy.preview_url in the minimal packet (check served pages with qa run --site)
|
|
327
327
|
campaigns-os bundle check --packet <campaign-runtime.build.json> [--require-qa] [--json] # validate the canonical migration/readback JSON bundle; never substitutes markdown
|
|
328
328
|
campaigns-os sdk storage-check --target <git-root> --target-sdk <x.y.z> --manifest <SDK-manifest.json> --scope <dir,file> [--exclude <dir,file>] [--json]
|
|
@@ -353,7 +353,7 @@ Usage:
|
|
|
353
353
|
campaigns-os logout [--store <subdomain>]
|
|
354
354
|
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
|
|
355
355
|
campaigns-os tooling diagnose [--packet <packet>] [--platform <claude|codex|agents|all>] [--json] # read-only redacted support summary
|
|
356
|
-
campaigns-os install-agent-context --target <page-kit-dir> [--dry-run]
|
|
356
|
+
campaigns-os install-agent-context --target <page-kit-dir> [--dry-run] [--json]
|
|
357
357
|
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
|
|
358
358
|
campaigns-os next setup --packet <json> [--context <json>] [--report <json>] [--json]
|
|
359
359
|
campaigns-os next build --packet <json> [--context <json>] [--report <json>] [--json]
|
|
@@ -361,7 +361,7 @@ Usage:
|
|
|
361
361
|
campaigns-os next deploy --packet <json> --report <json> [--json]
|
|
362
362
|
campaigns-os next qa --packet <json> --report <json> [--json]
|
|
363
363
|
campaigns-os qa resolve --packet <json> [--base-url <url>] [--no-probe] [--probe-timeout-ms <ms>] [--json] # probes the derived entry URLs; a dead route set reports routes_unresolved, an unprobed one ready_unprobed
|
|
364
|
-
campaigns-os qa run --packet <json> [--base-url <url>] [--currency <code>] [--browser] [--test-order <mode>] [--select-package <ref[:qty],...>] [--apply-coupon <code>] [--no-post-verdict] [--no-remit] [--no-live-refs] [--output-dir <dir>] [--json]
|
|
364
|
+
campaigns-os qa run --packet <json> [--base-url <url>] [--currency <code>] [--browser] [--test-order <mode>] [--select-package <ref[:qty],...>] [--apply-coupon <code>] [--test-email <email>] [--test-country <code> --test-address1 <line> --test-city <city> --test-province <code> --test-postal <code>] [--no-post-verdict] [--no-remit] [--no-live-refs] [--output-dir <dir>] [--json]
|
|
365
365
|
campaigns-os qa promote --packet <json> --verdict <full-verdict.json> [--json] # project one explicit qa-output verdict to the committed .campaign-runtime/qa-verdict.json sidecar
|
|
366
366
|
campaigns-os qa publish --packet <json> [--verdict <full-verdict.json>] [--republish] [--proxy-base <url>] [--dry-run] [--json] # post an already-stored verdict (the sidecar's run, or --verdict) to the QA portal without a re-run or an order; refuses a stale spec_hash or an already-published verdict. --dry-run runs every one of those refusal checks and prints what would be posted (endpoint, verdict run id, payload bytes) without the POST
|
|
367
367
|
campaigns-os qa policy set --packet <json> [--allowed-domains-confirmed true|false] [--deploy-target <target>] [--preview-url <url>] [--production-url <url>] [--order-path-depth <off|common|full>] [--json] # --order-path-depth writes qa.proof_policy.order_path_depth and refreshes the assembly report's proof_policy mirror
|
|
@@ -486,14 +486,14 @@ export async function main(argv, { authentication, qcStandIns } = {}) {
|
|
|
486
486
|
console.log(packageVersion());
|
|
487
487
|
return;
|
|
488
488
|
}
|
|
489
|
-
return
|
|
489
|
+
return withDoctorCiReport(parseArgs(argv), (args) => runInvocation(args, {
|
|
490
490
|
dispatch: (command, args, context) => dispatch(command, args, { ...context, argv, authentication, qcStandIns }),
|
|
491
491
|
closeOutStaleRunSessions,
|
|
492
492
|
ambientRunSession,
|
|
493
493
|
lifecycleIdentity,
|
|
494
494
|
persistLifecycle: persistLifecycleIfRequested,
|
|
495
495
|
autoEndAfterQa: autoEndRunSessionAfterTerminalQa,
|
|
496
|
-
});
|
|
496
|
+
}));
|
|
497
497
|
}
|
|
498
498
|
|
|
499
499
|
// The lifecycle entry's identity (T6): its argv shape and its run_id. Ambient
|
|
@@ -1051,8 +1051,15 @@ async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = nul
|
|
|
1051
1051
|
const liveCampaign = await readDoctorLiveCampaign(args);
|
|
1052
1052
|
const result = doctorCommand(args, { liveCampaign, qcStandIns });
|
|
1053
1053
|
if (!result.ok) recorder.recordFindingCodes(result.errors?.map((issue) => issue.code));
|
|
1054
|
-
|
|
1055
|
-
|
|
1054
|
+
if (args.ci === true) {
|
|
1055
|
+
const { formatDoctorCi, DOCTOR_CI_EXIT_CODES } = await import("./doctor/ci.mjs");
|
|
1056
|
+
if (!args.json) console.log(formatDoctorCi(result));
|
|
1057
|
+
else writeResult(result, args, 0);
|
|
1058
|
+
process.exitCode = result.ci?.exit_code ?? result.exit_code ?? DOCTOR_CI_EXIT_CODES.usage;
|
|
1059
|
+
} else {
|
|
1060
|
+
writeResult(result, args, result.ok ? 0 : 2);
|
|
1061
|
+
printDoctorTinyPrompt(result, args);
|
|
1062
|
+
}
|
|
1056
1063
|
return;
|
|
1057
1064
|
}
|
|
1058
1065
|
|
|
@@ -1174,8 +1181,15 @@ async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = nul
|
|
|
1174
1181
|
|
|
1175
1182
|
if (command === "install-agent-context") {
|
|
1176
1183
|
const target = requireArg(args, "target");
|
|
1177
|
-
const
|
|
1178
|
-
|
|
1184
|
+
const dryRun = isDryRun(args);
|
|
1185
|
+
const result = installAgentContext(resolve(target), dryRun);
|
|
1186
|
+
if (dryRun && !args.json) {
|
|
1187
|
+
for (const file of result.file_contents) {
|
|
1188
|
+
console.log(`Destination: ${file.destination}\n${file.content}`);
|
|
1189
|
+
}
|
|
1190
|
+
} else {
|
|
1191
|
+
writeResult(result, args, 0);
|
|
1192
|
+
}
|
|
1179
1193
|
return;
|
|
1180
1194
|
}
|
|
1181
1195
|
|
|
@@ -1847,6 +1861,7 @@ function prepareBuildUnderLock({
|
|
|
1847
1861
|
// campaign's report), and the previous brief carries field provenance.
|
|
1848
1862
|
const priorBriefInputs = priorBriefInputsForIntake({ reportPath, briefPath, identity: { spec: { map_id: mapId, ...(localSpecId ? { local_spec_id: localSpecId } : {}) }, campaign: { public_route_slug: publicRouteSlug } } });
|
|
1849
1863
|
const buildBrief = createCampaignBuildBriefArtifact({
|
|
1864
|
+
designFiles: resolveDesignFileReferences({ activePages, mappings: matched.mappings, manifest: manifestResult.manifest }).pages,
|
|
1850
1865
|
inputPath: briefDiscovery?.path || null,
|
|
1851
1866
|
inputSource: briefDiscovery?.source || null,
|
|
1852
1867
|
spec,
|
|
@@ -2287,7 +2302,7 @@ function assemblyThemeFromContext(theme) {
|
|
|
2287
2302
|
load_order: "unknown",
|
|
2288
2303
|
commerce_pages: [],
|
|
2289
2304
|
evidence: wroteCss
|
|
2290
|
-
? ["prepare-build auto-generated .campaign-runtime/theme/brand-theme.css; build should copy that existing artifact into campaign assets and load it after next-core.css on commerce pages."]
|
|
2305
|
+
? ["prepare-build auto-generated .campaign-runtime/theme/brand-theme.css; build should copy that existing artifact (and brand-fonts.css, when written) into campaign assets and load it after next-core.css on commerce pages."]
|
|
2291
2306
|
: theme.generated?.can_generate
|
|
2292
2307
|
? ["theme inspect found a generatable brand theme; run theme generate or explicit auto policy before applying."]
|
|
2293
2308
|
: ["theme inspect completed; no generated brand theme was applied during prepare-build."],
|
|
@@ -2807,6 +2822,8 @@ export async function polishCaptureCommand(args, options = {}) {
|
|
|
2807
2822
|
adapterCloseDeadlineMs: options.adapterCloseDeadlineMs,
|
|
2808
2823
|
probeClock: options.probeClock,
|
|
2809
2824
|
targetRepo,
|
|
2825
|
+
visual: { packetPath, targetRepo, ...options.visual },
|
|
2826
|
+
onFrozen: options.onFrozen,
|
|
2810
2827
|
});
|
|
2811
2828
|
// Nothing was measured: the held plan error stands.
|
|
2812
2829
|
if (heldPlanError && !capture.readability) throw heldPlanError;
|
|
@@ -2836,6 +2853,8 @@ export async function polishCaptureCommand(args, options = {}) {
|
|
|
2836
2853
|
pageLoad: capture.page_load,
|
|
2837
2854
|
mediaWeight: capture.media_weight,
|
|
2838
2855
|
readability: capture.readability,
|
|
2856
|
+
captures: capture.captures,
|
|
2857
|
+
elementParity: capture.element_parity,
|
|
2839
2858
|
now: options.now ?? new Date(),
|
|
2840
2859
|
});
|
|
2841
2860
|
checkpoint = evaluateRecordedHiddenEagerMediaCheckpoint({
|
|
@@ -2881,6 +2900,15 @@ export async function polishCaptureCommand(args, options = {}) {
|
|
|
2881
2900
|
route_scope: capture.plan?.route_scope ?? null,
|
|
2882
2901
|
routes: (capture.plan?.routes ?? []).map((route) => route.requested_route),
|
|
2883
2902
|
viewports: (capture.plan?.viewports ?? []).map((viewport) => viewport.key),
|
|
2903
|
+
visual: capture.captures ? { cells: capture.captures.cells.length,
|
|
2904
|
+
rendered: capture.captures.cells.filter((cell) => cell.status === "rendered").length,
|
|
2905
|
+
comparisons_compared: capture.captures.comparisons.filter((comparison) => comparison.status === "compared").length,
|
|
2906
|
+
comparisons_unsupported: capture.captures.comparisons.filter((comparison) => comparison.status === "unsupported").length,
|
|
2907
|
+
dependencies: capture.captures.dependencies, adapters: capture.captures.adapters,
|
|
2908
|
+
// A run budget spent: a cell never rendered, or a decode or diff never ran (§2.4).
|
|
2909
|
+
budget_exhausted: capture.captures.cells.some((cell) => cell.status === "run_budget_exhausted")
|
|
2910
|
+
|| capture.captures.references.some((reference) => reference.verification?.reason === "verify_budget_exhausted")
|
|
2911
|
+
|| capture.captures.comparisons.some((comparison) => comparison.reason === "diff_budget_exhausted") } : null,
|
|
2884
2912
|
},
|
|
2885
2913
|
};
|
|
2886
2914
|
}
|
|
@@ -3546,10 +3574,10 @@ export async function specDeriveFromStoreCommand(args, { fetchImpl = globalThis.
|
|
|
3546
3574
|
// the route each one builds to (filename-derived, or the file's permalink;
|
|
3547
3575
|
// a permalink derive cannot read as a campaign route carries `problem` and a
|
|
3548
3576
|
// null route). Discovery follows page-kit's own: every .html outside
|
|
3549
|
-
// `_layouts/` and `_includes
|
|
3550
|
-
//
|
|
3551
|
-
// than "no pages".
|
|
3552
|
-
function listPageKitPageFiles(outputDir, publicRouteSlug) {
|
|
3577
|
+
// `_layouts/` and `_includes/` (and, from page-kit 0.3.0, the top-level
|
|
3578
|
+
// `assets/`), symlinked entries included. Returns null when the directory
|
|
3579
|
+
// does not exist, so the plan can say "no page tree" rather than "no pages".
|
|
3580
|
+
function listPageKitPageFiles(outputDir, publicRouteSlug, { assetsHtmlIgnored = false } = {}) {
|
|
3553
3581
|
if (!outputDir || !existsSync(outputDir) || !statSync(outputDir).isDirectory()) return null;
|
|
3554
3582
|
const files = [];
|
|
3555
3583
|
const walk = (dir) => {
|
|
@@ -3563,8 +3591,7 @@ function listPageKitPageFiles(outputDir, publicRouteSlug) {
|
|
|
3563
3591
|
}
|
|
3564
3592
|
if (!stats.isFile() || extname(entry.name).toLowerCase() !== ".html") continue;
|
|
3565
3593
|
const path = relative(outputDir, fullPath).split(sep).join("/");
|
|
3566
|
-
|
|
3567
|
-
if (segments.slice(0, -1).some(isPageTreeIgnoredDir)) continue;
|
|
3594
|
+
if (isPageTreeIgnoredPath(path, { assetsHtmlIgnored })) continue;
|
|
3568
3595
|
// page-kit honours a permalink before its filename rule, so the
|
|
3569
3596
|
// frontmatter is read first (quoting, comments, BOM and CRLF as
|
|
3570
3597
|
// page-kit's reader takes them); only a file with no usable permalink
|
|
@@ -3773,7 +3800,7 @@ export function specDeriveCommand(args, { store: storeRead = null } = {}) {
|
|
|
3773
3800
|
try {
|
|
3774
3801
|
if (existsSync(outputDir)) {
|
|
3775
3802
|
const realOutputDir = realpathSync(outputDir);
|
|
3776
|
-
if (realOutputDir === realTargetRepo || realOutputDir.startsWith(`${realTargetRepo}${sep}`)) pageFiles = listPageKitPageFiles(realOutputDir, publicRouteSlug);
|
|
3803
|
+
if (realOutputDir === realTargetRepo || realOutputDir.startsWith(`${realTargetRepo}${sep}`)) pageFiles = listPageKitPageFiles(realOutputDir, publicRouteSlug, { assetsHtmlIgnored: pageKitIgnoresAssetsHtml(targetRepo) });
|
|
3777
3804
|
else addIssue(result.warnings, "spec.derive.page_tree_escapes_repo", `${outputDir} resolves to ${realOutputDir}, outside the target repo; routes were not read from it.`);
|
|
3778
3805
|
}
|
|
3779
3806
|
} catch (error) {
|
|
@@ -4318,7 +4345,7 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
|
|
|
4318
4345
|
}
|
|
4319
4346
|
}
|
|
4320
4347
|
const themeGate = doctor.derived?.theme_gate || null;
|
|
4321
|
-
const polishGate = doctor.derived?.polish_gate
|
|
4348
|
+
const polishGate = doctor.derived?.polish_gate;
|
|
4322
4349
|
const polishCheckpointGate = doctor.derived?.polish_checkpoint_gate || null;
|
|
4323
4350
|
// Packet 03 (INV-5 first slice): compare the ledger's self-report against
|
|
4324
4351
|
// the repo's artifacts before any recommendation goes out. Additive:
|
|
@@ -4398,21 +4425,25 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
|
|
|
4398
4425
|
const ready = [...doctor.ready];
|
|
4399
4426
|
if (!doctor.ok && !doctorHasOnlyPolishGateErrors) errors.push(...doctor.errors.map((issue) => withPacketSubstitutedIssue(issue, packetPath)));
|
|
4400
4427
|
|
|
4401
|
-
//
|
|
4402
|
-
//
|
|
4403
|
-
//
|
|
4404
|
-
//
|
|
4405
|
-
if (
|
|
4428
|
+
// prepare-build is upstream of every stage. While its authoritative gate is
|
|
4429
|
+
// blocked or unavailable, doctor's picker chooses prepare-build, and an
|
|
4430
|
+
// explicit stage request gets the same recovery-only shape (requested_stage
|
|
4431
|
+
// names it) instead of that stage's prompt or executable actions.
|
|
4432
|
+
if (prepareBuildGate) {
|
|
4406
4433
|
addPrepareBuildGateErrors(errors, report, prepareBuildGate);
|
|
4407
4434
|
return finalize({
|
|
4408
4435
|
ok: false,
|
|
4409
4436
|
status: "blocked",
|
|
4410
4437
|
stage: "prepare-build",
|
|
4411
|
-
requested_stage: stage,
|
|
4438
|
+
...(stage ? { requested_stage: stage } : {}),
|
|
4412
4439
|
reason: prepareBuildGate.reason,
|
|
4413
4440
|
errors,
|
|
4414
4441
|
warnings,
|
|
4415
4442
|
ready,
|
|
4443
|
+
// Packet 03: when the artifacts contradict the ledger, do NOT tell
|
|
4444
|
+
// the operator to start over — rerunning prepare-build/start is the
|
|
4445
|
+
// most destructive documented recovery, and the ledger alone is not
|
|
4446
|
+
// trustworthy evidence that it is needed.
|
|
4416
4447
|
prompt: prepareBuildRecoveryPrompt,
|
|
4417
4448
|
stage_blocked: true,
|
|
4418
4449
|
});
|
|
@@ -4438,34 +4469,16 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
|
|
|
4438
4469
|
prompt: `Resolve the doctor errors above before continuing. Re-run \`${cmd("doctor")} --packet <path> --write\` to record the recovery, then \`${cmd("next")} --packet <path>\` to advance.`,
|
|
4439
4470
|
});
|
|
4440
4471
|
}
|
|
4441
|
-
if (picked.stage === "prepare-build") {
|
|
4442
|
-
addPrepareBuildGateErrors(errors, report, prepareBuildGate);
|
|
4443
|
-
return finalize({
|
|
4444
|
-
ok: false,
|
|
4445
|
-
status: "blocked",
|
|
4446
|
-
stage: "prepare-build",
|
|
4447
|
-
reason: picked.reason,
|
|
4448
|
-
errors,
|
|
4449
|
-
warnings,
|
|
4450
|
-
ready,
|
|
4451
|
-
// Packet 03: when the artifacts contradict the ledger, do NOT tell
|
|
4452
|
-
// the operator to start over — rerunning prepare-build/start is the
|
|
4453
|
-
// most destructive documented recovery, and the ledger alone is not
|
|
4454
|
-
// trustworthy evidence that it is needed.
|
|
4455
|
-
prompt: prepareBuildRecoveryPrompt,
|
|
4456
|
-
stage_blocked: true,
|
|
4457
|
-
});
|
|
4458
|
-
}
|
|
4459
4472
|
if (picked.stage === "done") {
|
|
4460
4473
|
return finalize({
|
|
4461
4474
|
ok: true,
|
|
4462
|
-
status:
|
|
4475
|
+
status: readinessStatus(warnings, doctor.derived),
|
|
4463
4476
|
stage: "done",
|
|
4464
4477
|
reason: picked.reason,
|
|
4465
4478
|
errors,
|
|
4466
4479
|
warnings,
|
|
4467
4480
|
ready,
|
|
4468
|
-
prompt: `Pipeline complete. All stages in the assembly report are in a terminal status. To repeat build work, do the work and run \`${cmd("record")} build --packet <path>\`; this makes downstream stages owed as needed.
|
|
4481
|
+
prompt: `Pipeline complete. All stages in the assembly report are in a terminal status. To repeat build work, do the work and run \`${cmd("record")} build --packet <path>\`; this makes downstream stages owed as needed. \`${cmd("record")} deploy\` records the served deploy, a loopback URL for a local-serve target or an https URL for a hosted one. Run \`${cmd("qa")} run\` for QA. Then call \`${cmd("next")}\` again. If a run session is active, finish it with \`${cmd("run")} end\` so the Run Record is assembled and the session closes.${report?.stages?.qa?.status === "completed_with_warnings" ? " QA passed with exceptions; report them to the operator without clearing or waiving them or changing markup just to make them pass." : ""}`,
|
|
4469
4482
|
});
|
|
4470
4483
|
}
|
|
4471
4484
|
stage = picked.stage;
|
|
@@ -4509,13 +4522,7 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
|
|
|
4509
4522
|
addThemeGateErrors(errors, themeGate, "qa");
|
|
4510
4523
|
prompt = qaPrompt(packetPath, reportPath, packet, spec, intentSummary, polishCheckpointGate);
|
|
4511
4524
|
}
|
|
4512
|
-
const status = errors.length
|
|
4513
|
-
? "blocked"
|
|
4514
|
-
: checkpointExceptionPresent(doctor.derived)
|
|
4515
|
-
? "ready_with_waivers"
|
|
4516
|
-
: warnings.length
|
|
4517
|
-
? "ready_with_warnings"
|
|
4518
|
-
: "ready";
|
|
4525
|
+
const status = errors.length ? "blocked" : readinessStatus(warnings, doctor.derived);
|
|
4519
4526
|
const result = {
|
|
4520
4527
|
ok: errors.length === 0,
|
|
4521
4528
|
status,
|
|
@@ -5094,7 +5101,7 @@ Rules:
|
|
|
5094
5101
|
- Prepared AI/exported HTML must be converted into page-kit-ready source first: keep page-owned body markup, strip document wrappers, add YAML frontmatter, move shared CSS/assets into the campaign structure, and use Liquid helpers only for page-kit links/assets/includes. Page Kit publishes src/<slug>/assets/config.js as /<slug>/config.js and src/<slug>/assets/products/foo.png as /<slug>/products/foo.png; do not leave raw /assets/... or /<slug>/assets/... references in rendered pages.
|
|
5095
5102
|
- Preserve prepared source HTML for landing/presell pages when it is a real standalone design.
|
|
5096
5103
|
- For checkout/upsell/downsell/receipt, use the starter template as the SDK contract reference only: preserve required data-next controls and runtime wiring, but let the campaign/source own visual chrome, copy hierarchy, imagery, and brand layer.
|
|
5097
|
-
- Read context.theme and .campaign-runtime/theme/theme-report.json when present. If a fresh brand-theme.css exists, copy it into the campaign assets/css folder and list it after next-core.css in checkout/upsell/downsell/receipt frontmatter styles; if policy is inspect_only, generate or skip explicitly before applying a new brand layer.
|
|
5104
|
+
- Read context.theme and .campaign-runtime/theme/theme-report.json when present. If a fresh brand-theme.css exists, copy it into the campaign assets/css folder and list it after next-core.css in checkout/upsell/downsell/receipt frontmatter styles; when theme generate also wrote brand-fonts.css (the design's own faces), copy it beside brand-theme.css and list it on the same pages; if policy is inspect_only, generate or skip explicitly before applying a new brand layer.
|
|
5098
5105
|
- Generated brand-theme.css v0 is root-variable-only. Do not edit SDK-owned selectors, package controls, payment fields, totals, submit controls, receipt templates, route meta tags, or SDK JavaScript as part of theme application.
|
|
5099
5106
|
- If you copy starter-template files, copy the selected family atomically with dependent pages, _includes, _layouts, assets/css, and assets/js; do not copy only checkout.html and receipt.html.
|
|
5100
5107
|
- Resolve SDK routing meta tags to campaign-root paths such as ${campaignRouteRoot(packet) || `/${packet.campaign.public_route_slug}/`}upsell/, not source filenames or unrooted spec literals.
|
|
@@ -5105,7 +5112,7 @@ Rules:
|
|
|
5105
5112
|
- Each checkout_step page's checkout form (<form data-next-checkout="form">) carries data-next-checkout-step="<its next_step>" and data-next-step-number="<step_number>" (Build Context page_kit frontmatter next_step and step_number); the checkout page's form carries neither. On select and checkout_step pages, frontmatter next_url is the Checkout's post-payment destination, the same meaning it has on the checkout page, and the layout renders it as next-success-url because the SDK reads that tag for express orders; next_step is the forward link. A landing page keeps next_url as its call-to-action target; if it has express checkout buttons, set its next-success-url to the Checkout's own next_url from the Build Context page_map, never to the landing page's next_url. Forward navigation uses the step form attribute or a plain link, never that meta tag. Page Kit frontmatter has no success_url key (success_url remains a CampaignSpec field on checkout pages).
|
|
5106
5113
|
- After page-kit build, inspect rendered _site output before handoff: each active page should have a body, Campaign Cart runtime markers, SDK meta tags from CampaignSpec sdk_hints.meta_tags, and no stale copied funnel attribution.
|
|
5107
5114
|
- Run page-kit build and SDK/template lint, then record build before polish: \`${cmd("record")} build --packet ${packetPath}${isLocalServePacket(packet) ? ` --build-environment ${LOCAL_PROOF_BUILD_ENVIRONMENT}` : ""} --adapter-decision <key>=<value>[,<key>=<value>...]\`. Record the value that is true for this build: for example, raw_html_conversion_status=completed once source HTML is converted, or not_required when there is none. Put all scalar adapter decisions in one comma-separated flag; a repeated flag keeps only the last. Use the allowed values in docs/build-packet.md. Record the nine recordable scalar decisions with the command, never by editing those fields in the Assembly Report. wrapper_policy is selected at intake with prepare-build --wrapper-policy or the source-html manifest's wrapper_policy option; the object-valued template_files_copied proof has no flag. It stamps stages.assembly.build_fingerprint with the fingerprint doctor computes from the built output (derived.build_output_fingerprint.value, sha256 over the sorted path+sha256 manifest of _site/<slug>/; doctor reports built_output.fingerprint_stale whenever the output on disk stops matching the recorded value), records report.design_source_package.material_fingerprint on stages.assembly.source_package_material_fingerprint when present, and sets stages.polish to "required" (required_by="build", required_for=["qa"]). Re-run it after every rebuild; never hand-edit these fields. Build must not mark stages.polish as completed/completed_with_warnings/skipped. If you applied a brand theme, run \`${cmd("record")} theme --packet ${packetPath}\` after record build: it reads each built commerce page's stylesheet links and records report.theme (status applied, load_order=after-next-core, css_path, commerce_pages, evidence), and refuses, writing nothing, when a page that loads next-core.css does not load the brand layer after it. Never hand-edit report.theme.
|
|
5108
|
-
- Capture the machine-readable build summary as an artifact: \`${PAGE_KIT_BUILD_SUMMARY_CAPTURE_COMMAND}\` (requires next-campaign-page-kit >= 0.1.4). Doctor verifies it for per-page build errors and Page Kit shape warnings (NESTED_NO_PERMALINK, DUPLICATE_OUTPUT, MISSING_FRONTMATTER, LAYOUT_NOT_FOUND). If the installed page-kit predates --json, record that in the assembly report instead of skipping silently.${localProofPromptLines(packet, packetPath)}`;
|
|
5115
|
+
- Capture the machine-readable build summary as an artifact: \`${PAGE_KIT_BUILD_SUMMARY_CAPTURE_COMMAND}\` (requires next-campaign-page-kit >= 0.1.4). Doctor verifies it for per-page build errors and Page Kit shape warnings (NESTED_NO_PERMALINK, DUPLICATE_OUTPUT, MISSING_FRONTMATTER, LAYOUT_NOT_FOUND), and on page-kit >= 0.3.0 for its top-level assetWarnings: ASSET_IGNORED names an .html file under assets/ that is neither built nor copied, so a link or iframe to it 404s. If the installed page-kit predates --json, record that in the assembly report instead of skipping silently.${localProofPromptLines(packet, packetPath)}`;
|
|
5109
5116
|
}
|
|
5110
5117
|
|
|
5111
5118
|
// Local proof mode lines for the build prompt: under local-serve the build is
|
|
@@ -5248,10 +5255,8 @@ function localServePlan(packet) {
|
|
|
5248
5255
|
}
|
|
5249
5256
|
|
|
5250
5257
|
function deployPrompt(packetPath, reportPath, packet, polishGate = null, polishCheckpointGate = null) {
|
|
5251
|
-
if (packet
|
|
5252
|
-
|
|
5253
|
-
&& !packet.deploy?.preview_url) {
|
|
5254
|
-
return `Every mapped page is template stock, and the hosted preview URL is not recorded. Run \`${cmd("qa")} policy set --packet ${shellToken(packetPath)} --preview-url <url>\` after the preview is deployed, then \`${cmd("next")} --packet ${shellToken(packetPath)}\`.`;
|
|
5258
|
+
if (hostedTemplateNeedsUsablePreview(packet, polishCheckpointGate)) {
|
|
5259
|
+
return `Every mapped page is template stock, and deploy needs a hosted preview URL that differs from the production URL. Run \`${cmd("qa")} policy set --packet ${shellToken(packetPath)} --preview-url <url>\` after the preview is deployed, then \`${cmd("next")} --packet ${shellToken(packetPath)}\`.`;
|
|
5255
5260
|
}
|
|
5256
5261
|
const target = packet.deploy?.target || "unknown";
|
|
5257
5262
|
const liveUrlPath = packet.deploy?.live_url_path || packet.campaign?.live_url_path || campaignRouteRoot(packet) || "/<slug>/";
|
|
@@ -5344,10 +5349,8 @@ function orderBumpRunReason(bumpCart) {
|
|
|
5344
5349
|
}
|
|
5345
5350
|
|
|
5346
5351
|
function qaPrompt(packetPath, reportPath, packet, spec = null, intent, polishCheckpointGate = null) {
|
|
5347
|
-
if (packet
|
|
5348
|
-
|
|
5349
|
-
&& !packet.deploy?.preview_url) {
|
|
5350
|
-
return `Every mapped page is template stock, and QA needs the hosted preview URL. Run \`${cmd("qa")} policy set --packet ${shellToken(packetPath)} --preview-url <url>\`, then \`${cmd("next")} qa --packet ${shellToken(packetPath)}\`.`;
|
|
5352
|
+
if (hostedTemplateNeedsUsablePreview(packet, polishCheckpointGate)) {
|
|
5353
|
+
return `Every mapped page is template stock, and QA needs a hosted preview URL that differs from the production URL. Run \`${cmd("qa")} policy set --packet ${shellToken(packetPath)} --preview-url <url>\`, then \`${cmd("next")} --packet ${shellToken(packetPath)}\`.`;
|
|
5351
5354
|
}
|
|
5352
5355
|
const url = packet.deploy?.preview_url || packet.deploy?.production_url || "<preview-url>";
|
|
5353
5356
|
const briefPath = packet.build_brief?.normalized_path || "(missing)";
|
|
@@ -5493,10 +5496,13 @@ function installAgentContext(targetRepo, dryRun = false) {
|
|
|
5493
5496
|
["copilot-instructions.md", join(ROOT, "agents/copilot/copilot-instructions.md")],
|
|
5494
5497
|
];
|
|
5495
5498
|
const written = [];
|
|
5499
|
+
const fileContents = [];
|
|
5496
5500
|
if (!dryRun) mkdirSync(outDir, { recursive: true });
|
|
5497
5501
|
for (const [name, source] of files) {
|
|
5498
5502
|
const dest = join(outDir, name);
|
|
5499
|
-
|
|
5503
|
+
const content = readFileSync(source, "utf8");
|
|
5504
|
+
if (!dryRun) writeFileSync(dest, content);
|
|
5505
|
+
else fileContents.push({ destination: dest, content });
|
|
5500
5506
|
written.push(dest);
|
|
5501
5507
|
}
|
|
5502
5508
|
const gitignore = ensureRuntimeStateIgnored(targetRepo, { dryRun });
|
|
@@ -5506,6 +5512,7 @@ function installAgentContext(targetRepo, dryRun = false) {
|
|
|
5506
5512
|
target_repo: targetRepo,
|
|
5507
5513
|
directory: outDir,
|
|
5508
5514
|
files: written,
|
|
5515
|
+
...(dryRun ? { file_contents: fileContents } : {}),
|
|
5509
5516
|
gitignore,
|
|
5510
5517
|
note: "Context files are staged under .campaign-runtime/agent-context and do not overwrite root agent files.",
|
|
5511
5518
|
};
|
|
@@ -6107,7 +6114,12 @@ function toolingCommand(args) {
|
|
|
6107
6114
|
? stalePlatforms.map((platform) => ["--platform", platform])
|
|
6108
6115
|
: [["--platform", args.platform || "all"]];
|
|
6109
6116
|
const commands = invocations.map((skillArgs) => `${cli.invocation_prefix} install-skills ${skillArgs.join(" ")}`);
|
|
6110
|
-
|
|
6117
|
+
// A per-platform refresh names the harnesses it is for, so a session in
|
|
6118
|
+
// another harness does not read it as its own required step (#683).
|
|
6119
|
+
const forHarnesses = !args.target && skillScope.scope === "installed_platforms" && stalePlatforms.length < SKILL_PLATFORMS.length
|
|
6120
|
+
? ` (for ${stalePlatforms.map((id) => SKILL_PLATFORMS.find((platform) => platform.id === id)?.label || id).join(" and ")}; not needed in a session that uses another harness)`
|
|
6121
|
+
: "";
|
|
6122
|
+
actions.push(`Refresh installed skills: ${commands.join(" and ")}${forHarnesses}. ${SKILLS_READ_NOW_FOLLOW_UP}`);
|
|
6111
6123
|
}
|
|
6112
6124
|
|
|
6113
6125
|
if (install.mode === "checkout" && cli.global_binary.status === "not_found") {
|
|
@@ -7117,6 +7129,12 @@ function runSessionStatus(args, ambient = null) {
|
|
|
7117
7129
|
// Session progress: incomplete assembly-report stages, the deviation count,
|
|
7118
7130
|
// and the exact next command. Read-only and best-effort; the status command
|
|
7119
7131
|
// must never fail because an artifact is missing or torn.
|
|
7132
|
+
//
|
|
7133
|
+
// The incomplete stages are the recorded statuses. Whether every stage is
|
|
7134
|
+
// actually done is `next`'s decision, not this one: an input change or a new
|
|
7135
|
+
// build owes a stage again while its recorded status still reads completed.
|
|
7136
|
+
// So the next command is always `next`, which names `run end` once the
|
|
7137
|
+
// campaign is done and a session is active.
|
|
7120
7138
|
function runSessionProgress(found) {
|
|
7121
7139
|
const packetPath = found.session.packet;
|
|
7122
7140
|
if (!isNonEmptyString(packetPath) || !existsSync(packetPath)) return null;
|
|
@@ -7126,7 +7144,7 @@ function runSessionProgress(found) {
|
|
|
7126
7144
|
const incomplete = [];
|
|
7127
7145
|
for (const key of ASSEMBLY_REPORT_STAGE_KEYS) {
|
|
7128
7146
|
const status = String(report?.stages?.[key]?.status || "");
|
|
7129
|
-
if (!
|
|
7147
|
+
if (!stageIsTerminal(status)) {
|
|
7130
7148
|
incomplete.push({ stage: key, status: status || null });
|
|
7131
7149
|
}
|
|
7132
7150
|
}
|
|
@@ -7135,9 +7153,7 @@ function runSessionProgress(found) {
|
|
|
7135
7153
|
incomplete_stages: incomplete,
|
|
7136
7154
|
deviations,
|
|
7137
7155
|
qa_attempt_count: Array.isArray(found.session.qa_attempts) ? found.session.qa_attempts.length : 0,
|
|
7138
|
-
next_command:
|
|
7139
|
-
? `${cmd("next")} --packet ${packetPath} --json`
|
|
7140
|
-
: `${cmd("run")} end --packet ${packetPath}`,
|
|
7156
|
+
next_command: `${cmd("next")} --packet ${packetPath} --json`,
|
|
7141
7157
|
};
|
|
7142
7158
|
} catch {
|
|
7143
7159
|
return null;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { collectFigmaProvenanceFindings } from "./doctor/source-provenance.mjs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { canonicalJson } from "./polish-capture.mjs";
|
|
4
|
+
|
|
5
|
+
const VIEWPORTS = ["desktop", "tablet", "mobile"];
|
|
6
|
+
const nonEmpty = (value) => typeof value === "string" && value.trim().length > 0;
|
|
7
|
+
const object = (value) => value && typeof value === "object" && !Array.isArray(value);
|
|
8
|
+
const absoluteHttps = (value) => {
|
|
9
|
+
if (!nonEmpty(value)) return null;
|
|
10
|
+
try {
|
|
11
|
+
const url = new URL(value);
|
|
12
|
+
return url.protocol === "https:" ? url : null;
|
|
13
|
+
} catch { return null; }
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// Declared links are data. No design-file reader or network access lives here.
|
|
17
|
+
export function normalizeDesignFileReference(value) {
|
|
18
|
+
const warnings = [];
|
|
19
|
+
const url = object(value) && absoluteHttps(value.file_url);
|
|
20
|
+
if (!url) return { declaration: null, warnings: ["file_url must be a non-empty absolute https: URL"] };
|
|
21
|
+
const type = nonEmpty(value.type) ? value.type.toLowerCase() : (url.hostname === "figma.com" || url.hostname.endsWith(".figma.com")) ? "figma" : "other";
|
|
22
|
+
let fileKey = null;
|
|
23
|
+
if (type === "figma") {
|
|
24
|
+
const parts = url.pathname.split("/");
|
|
25
|
+
const index = parts.findIndex((part) => ["file", "design", "proto", "board"].includes(part));
|
|
26
|
+
let candidate = index < 0 ? null : parts[index + 1];
|
|
27
|
+
const branch = parts.indexOf("branch", index + 2);
|
|
28
|
+
if (index >= 0 && branch >= 0 && /^[A-Za-z0-9]+$/.test(parts[branch + 1] ?? "")) candidate = parts[branch + 1];
|
|
29
|
+
if (typeof candidate === "string" && /^[A-Za-z0-9]+$/.test(candidate)) fileKey = candidate;
|
|
30
|
+
}
|
|
31
|
+
const identity = fileKey == null ? { type, url: url.origin + url.pathname } : { type, file_key: fileKey };
|
|
32
|
+
const file_identity_sha256 = createHash("sha256").update(canonicalJson(identity)).digest("hex");
|
|
33
|
+
const frames = {};
|
|
34
|
+
for (const viewport of VIEWPORTS) {
|
|
35
|
+
if (!object(value.breakpoints) || !Object.hasOwn(value.breakpoints, viewport)) continue;
|
|
36
|
+
const link = value.breakpoints[viewport];
|
|
37
|
+
let frame;
|
|
38
|
+
try { frame = nonEmpty(link) ? new URL(link) : null; } catch { frame = null; }
|
|
39
|
+
if (!frame) { warnings.push(`breakpoints.${viewport} must be an absolute frame URL`); continue; }
|
|
40
|
+
frames[viewport] = { url: link, node_id: frame.searchParams.get("node-id")?.replaceAll("-", ":") ?? null };
|
|
41
|
+
}
|
|
42
|
+
return { declaration: { type, file_url: value.file_url, file_key: fileKey, identity, file_identity_sha256, frames }, warnings };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function resolveDesignFileReferences({ activePages = [], mappings = [], manifest = null, specChannel = { mode: "intake" } } = {}) {
|
|
46
|
+
const pages = [];
|
|
47
|
+
const files = new Map();
|
|
48
|
+
// Intake records only Figma spec pointers backed by #534's provenance test.
|
|
49
|
+
// Doctor first uses today's intake declaration. Only a page with recorded
|
|
50
|
+
// design-file lineage can fall back to its Figma spec without provenance.
|
|
51
|
+
// Manifest declarations stand alone; a shared file's producer is not per-page.
|
|
52
|
+
const exportProvenance = manifest != null && !collectFigmaProvenanceFindings(manifest).some((finding) => finding.severity === "provenance");
|
|
53
|
+
const producer = manifest?.producer_provenance;
|
|
54
|
+
const producerKeys = new Set([producer?.figma_file_key, ...(Array.isArray(producer?.figma_file_keys) ? producer.figma_file_keys : [])].filter(nonEmpty));
|
|
55
|
+
for (const page of activePages) {
|
|
56
|
+
const mapping = mappings.find((entry) => entry?.page_id === page.id);
|
|
57
|
+
if (!nonEmpty(mapping?.path)) continue;
|
|
58
|
+
const matches = (Array.isArray(manifest?.pages) ? manifest.pages : []).filter((entry) => entry?.page_id === page.id && entry.path === mapping.path);
|
|
59
|
+
const manifestValue = matches.length === 1 ? matches[0].design_file : null;
|
|
60
|
+
const specDeclaration = normalizeDesignFileReference(page.design_source).declaration;
|
|
61
|
+
const fromManifest = normalizeDesignFileReference(manifestValue);
|
|
62
|
+
const intakeSpec = specDeclaration?.type === "figma" && exportProvenance ? specDeclaration : null;
|
|
63
|
+
const fallbackSpec = !intakeSpec && !fromManifest.declaration
|
|
64
|
+
&& specChannel.mode === "doctor" && specChannel.recordedPageIds?.has(page.id)
|
|
65
|
+
&& specDeclaration?.type === "figma" ? specDeclaration : null;
|
|
66
|
+
const fromSpec = intakeSpec || fallbackSpec;
|
|
67
|
+
const declaration = fromSpec || fromManifest.declaration;
|
|
68
|
+
if (!declaration) continue;
|
|
69
|
+
const declared_by = fromSpec ? "campaign_spec" : "source_html_manifest";
|
|
70
|
+
let conflict = null;
|
|
71
|
+
// Provenance decides which declaration records lineage, never whether a
|
|
72
|
+
// Figma spec that disagrees with the manifest is reported.
|
|
73
|
+
const specNamed = fromSpec || (specDeclaration?.type === "figma" ? specDeclaration : null);
|
|
74
|
+
if (specNamed && fromManifest.declaration) {
|
|
75
|
+
const other = fromManifest.declaration;
|
|
76
|
+
const frames = VIEWPORTS.filter((viewport) => specNamed.frames[viewport] && other.frames[viewport] && specNamed.frames[viewport].node_id !== other.frames[viewport].node_id).sort();
|
|
77
|
+
if (specNamed.file_identity_sha256 !== other.file_identity_sha256 || frames.length) {
|
|
78
|
+
conflict = { kind: "spec_vs_manifest", identities: [specNamed.file_identity_sha256, other.file_identity_sha256].sort(), ...(frames.length ? { frames } : {}) };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (!conflict && declaration.type === "figma" && declaration.file_key && producerKeys.size && !producerKeys.has(declaration.file_key)) {
|
|
82
|
+
conflict = { kind: "producer_file_key", identities: [declaration.file_identity_sha256] };
|
|
83
|
+
}
|
|
84
|
+
pages.push({ page_id: page.id, declaration, declared_by, conflict, warnings: manifestValue == null ? [] : fromManifest.warnings });
|
|
85
|
+
const sha = declaration.file_identity_sha256;
|
|
86
|
+
if (!files.has(sha)) files.set(sha, { contribution_id: `design-file-${sha.slice(0, 12)}`, identity: declaration.identity, file_url: declaration.file_url, producer: declared_by, pages: [] });
|
|
87
|
+
const file = files.get(sha);
|
|
88
|
+
// A shared file keeps the manifest's provenance if any page declared it there.
|
|
89
|
+
if (declared_by === "source_html_manifest") file.producer = declared_by;
|
|
90
|
+
file.pages.push(page.id);
|
|
91
|
+
}
|
|
92
|
+
return { pages, files: [...files.values()] };
|
|
93
|
+
}
|