@topy-ai/maggie 0.7.34 → 0.7.36

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/README-zh-TW.md CHANGED
@@ -8,7 +8,7 @@ Codex、Claude Code 與相容的 coding agents。
8
8
  ## 安裝
9
9
 
10
10
  ```bash
11
- npx @topy-ai/maggie@0.7.34 init --agent all
11
+ npx @topy-ai/maggie@0.7.36 init --agent all
12
12
  npx @topy-ai/maggie doctor --project .
13
13
  ```
14
14
 
@@ -25,7 +25,7 @@ maggie doctor --project . --require-bootstrap --strict
25
25
  deployment、memory、feedback 和 MaggieDash。內容先 draft/review,外部寫入、
26
26
  publish 與 production deployment 需要明確確認。
27
27
 
28
- 目前 release 是 `0.7.34`,完成下一批已驗證 feedback:production fixture isolation、MaggieDash project-id inference、responsive/background asset extraction、穩定 SEO structure hash、decorative alt、明確 site URL/build validation、真正 404、Tailwind v4 starter、portable CLI scripts,以及不含 local dependencies/cache 的 Astro starter bundle。延後功能記錄在 issue #38,不會直接成為 active memory。0.7.33 記錄並審查一筆更正回饋;確認 0.7.32 已正確處理 batch index,沒有修改已驗證的 shared behavior。0.7.32 加入 feedback 一基索引支援、MaggieDash panel 的 source/freshness/error evidence、Consent Mode 與實際 write/no-op reconciliation、SEO baseline recapture、sitemap origin rebasing、content-diff scope、migration ledger reconciliation、deployment credential preflight、release provenance/runtime preflight、icon release gate、opt-in blog auto-publish gate,以及 least-privilege VPS deployer。也修復 top-level `maggie feedback` dispatcher parity。0.7.31 加入 update 差異摘要、atomic installer copy、subset install manifest merge,以及 inventory drift 時 doctor non-zero。0.7.30 補上 feedback tracking issue closeout gate 與 batch 14/15 audit ledger。0.7.29 修正 npm package landing page 使用英文 README;0.7.28 加入 versioned Gemini model policy、明確 fallback 與 provenance、bundled-first CLI
28
+ 目前 release 是 `0.7.36`,完成下一批已驗證 feedback:修正 doctor placeholder scope、Tailwind renderable source scope、internal link/fragment audit、unstable baseline classification、site-wide baseline reason、base64url asset hash、installed-file hash update safety,並加入 provider-routed Gemini v2、venue timezone/DST booking parsing、social-card release path、notification lifecycle contract 和 mobile hero video playback contract。provider-bound video generation 與 transactional delivery 已記錄在 issue #39;前一批延後功能仍在 issue #38,不會直接成為 active memory。0.7.33 記錄並審查一筆更正回饋;確認 0.7.32 已正確處理 batch index,沒有修改已驗證的 shared behavior。0.7.32 加入 feedback 一基索引支援、MaggieDash panel 的 source/freshness/error evidence、Consent Mode 與實際 write/no-op reconciliation、SEO baseline recapture、sitemap origin rebasing、content-diff scope、migration ledger reconciliation、deployment credential preflight、release provenance/runtime preflight、icon release gate、opt-in blog auto-publish gate,以及 least-privilege VPS deployer。也修復 top-level `maggie feedback` dispatcher parity。0.7.31 加入 update 差異摘要、atomic installer copy、subset install manifest merge,以及 inventory drift 時 doctor non-zero。0.7.30 補上 feedback tracking issue closeout gate 與 batch 14/15 audit ledger。0.7.29 修正 npm package landing page 使用英文 README;0.7.28 加入 versioned Gemini model policy、明確 fallback 與 provenance、bundled-first CLI
29
29
  dispatch、`maggie --version`、feedback batch review 聚合與重複偵測、marketplace enrichment evidence、booking
30
30
  worker/resolver evidence,以及 read-only migration preflight。它也包含 host-owned mobile app surface contract、signed-in
31
31
  camera-state QA、直接 Astro route resolution、correlated feedback batch,以及
package/README.md CHANGED
@@ -60,14 +60,22 @@ Maggie keeps the existing project foundation and asks for decisions before
60
60
  shared routes, analytics, or publishing boundaries change. The current
61
61
  package ships 19 installable skills and a local-first MaggieDash foundation.
62
62
 
63
- The current release is `0.7.34`. It closes the next validated feedback batch:
63
+ The current release is `0.7.36`. It closes the next validated feedback batch:
64
64
  production demo fixtures are development-only, Dash infers the persisted
65
65
  project id, clone asset extraction includes responsive/background assets, SEO
66
66
  structure hashes ignore content-hashed asset names, decorative empty alt text
67
67
  is accepted, and missing starter routes return a real 404. It also adds a
68
68
  Tailwind v4 starter entry point, explicit `PUBLIC_SITE_URL` validation, a
69
69
  portable installed-CLI script path, and a bundled Astro starter without local
70
- dependencies or build caches. Deferred feature requests are tracked in
70
+ dependencies or build caches. Batch 20 also adds rendered-output placeholder
71
+ scoping, Tailwind renderable source boundaries, same-origin link/fragment
72
+ checks, unstable baseline classification, site-wide baseline reasons,
73
+ base64url asset-hash normalization, installed-file update hashes, provider-
74
+ routed Gemini v2, venue-timezone/DST booking parsing, social-card release-path
75
+ discovery, a booking notification lifecycle contract, and mobile hero-video
76
+ playback guidance. Provider-bound video generation and transactional delivery
77
+ are tracked in [issue #39](https://github.com/TOPY-AI-LTD/ai-cmo-skills/issues/39).
78
+ Deferred feature requests from the prior batch remain in
71
79
  [issue #38](https://github.com/TOPY-AI-LTD/ai-cmo-skills/issues/38), not active
72
80
  memory. The 0.7.33 release records the reviewed correction for the
73
81
  batch-index feedback workflow while keeping the verified 0.7.32 behavior
@@ -509,8 +517,8 @@ artifact schemas.
509
517
  Recommended upgrade sequence for the current release:
510
518
 
511
519
  ```bash
512
- npx @topy-ai/maggie@0.7.34 update --project . --force
513
- npx @topy-ai/maggie@0.7.34 cleanup --project .
520
+ npx @topy-ai/maggie@0.7.36 update --project . --force
521
+ npx @topy-ai/maggie@0.7.36 cleanup --project .
514
522
  ```
515
523
 
516
524
  Maintainers should pass npm credentials through the repository helper, never
package/bin/maggie.js CHANGED
@@ -3,7 +3,7 @@
3
3
  import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { createHash } from "node:crypto";
5
5
  import { spawnSync } from "node:child_process";
6
- import { basename, dirname, join, resolve } from "node:path";
6
+ import { basename, dirname, join, relative, resolve } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
 
9
9
  const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
@@ -132,9 +132,9 @@ Usage:
132
132
  maggie seo performance|images|sitemap|indexnow|social-cards|head-tags [options] (sitemap supports strict validate and agent-files)
133
133
  maggie feedback <collect|preview|batch|batch-review|submit|list> [options]
134
134
  maggie qa <start|record|summary|export|assertion-audit> [options]
135
- maggie site-audit URL [--crawl] [--access-log FILE] [--require-sitemap-request] [--languages en-GB,es-MX,ja-JP] [--check-hreflang]
135
+ maggie site-audit URL [--crawl] [--access-log FILE] [--require-sitemap-request] [--languages en-GB,es-MX,ja-JP] [--check-hreflang] [--check-internal-links]
136
136
  maggie site-audit URL --crawl --save-baseline FILE --reviewer NAME
137
- maggie site-audit URL --crawl --baseline FILE
137
+ maggie site-audit URL --crawl --baseline FILE [--reason-all REASON]
138
138
  maggie browser-audit URL --browse PATH --output DIR --required SELECTOR [--sticky SELECTOR]
139
139
  maggie verification coverage --contract FILE --evidence FILE
140
140
  maggie ops audit|preflight|verify|lockfiles|seed-manifest|google-capabilities|favicon-check --project PATH
@@ -282,21 +282,25 @@ function diffSummary(source, target) {
282
282
  return `${added} local additions, ${removed} package lines removed across ${blocks} changed blocks (${localLines.length} local lines vs ${packageLines.length} package lines)`;
283
283
  }
284
284
 
285
- function syncTree(source, target, force) {
285
+ function syncTree(source, target, force, root = null, managedFiles = {}) {
286
286
  let changed = 0;
287
287
  for (const entry of readdirSync(source, { withFileTypes: true })) {
288
288
  const sourcePath = join(source, entry.name);
289
289
  const targetPath = join(target, entry.name);
290
290
  if (entry.isDirectory()) {
291
- changed += syncTree(sourcePath, targetPath, force);
291
+ changed += syncTree(sourcePath, targetPath, force, root, managedFiles);
292
292
  continue;
293
293
  }
294
+ const managedPath = root ? relative(root, targetPath) : targetPath;
295
+ const localDigest = existsSync(targetPath) ? digest(targetPath) : null;
296
+ const packageDigest = digest(sourcePath);
297
+ const wasManaged = managedFiles && managedFiles[managedPath] === localDigest;
294
298
  if (!existsSync(targetPath)) {
295
299
  mkdirSync(dirname(targetPath), { recursive: true });
296
300
  writeFileSync(targetPath, readFileSync(sourcePath));
297
301
  console.log(`installed ${targetPath}`);
298
302
  changed++;
299
- } else if (force || digest(sourcePath) === digest(targetPath)) {
303
+ } else if (force || packageDigest === localDigest || wasManaged) {
300
304
  writeFileSync(targetPath, readFileSync(sourcePath));
301
305
  console.log(`updated ${targetPath}`);
302
306
  changed++;
@@ -307,6 +311,59 @@ function syncTree(source, target, force) {
307
311
  return changed;
308
312
  }
309
313
 
314
+ function collectManagedFiles(source, target, root, managedFiles, previousManagedFiles = {}) {
315
+ if (!existsSync(source) || !existsSync(target)) return;
316
+ const sourceStat = readdirSync;
317
+ let entries;
318
+ try {
319
+ entries = sourceStat(source, { withFileTypes: true });
320
+ } catch {
321
+ const targetDigest = digest(target);
322
+ const relativePath = relative(root, target);
323
+ if (targetDigest === digest(source) || previousManagedFiles[relativePath] === targetDigest) managedFiles[relativePath] = targetDigest;
324
+ return;
325
+ }
326
+ for (const entry of entries) {
327
+ collectManagedFiles(join(source, entry.name), join(target, entry.name), root, managedFiles, previousManagedFiles);
328
+ }
329
+ }
330
+
331
+ function managedPairs(root, roots, skills) {
332
+ const pairs = [];
333
+ const add = (source, target) => { if (existsSync(source) && existsSync(target)) pairs.push([source, target]); };
334
+ for (const agentRoot of roots) {
335
+ for (const skill of skills) add(join(SKILLS_ROOT, skill), join(agentRoot, "skills", skill));
336
+ add(REFERENCES_ROOT, join(agentRoot, "references"));
337
+ }
338
+ for (const group of ["clis", "runtime", "integrations"]) add(join(TOOLS_ROOT, group), join(root, "tools", group));
339
+ add(DESIGN_ROOT, join(root, ".maggie", "design-reference"));
340
+ add(MARKETPLACE_ROOT, join(root, "marketplace"));
341
+ add(CONTRACTS_ROOT, join(root, "contracts"));
342
+ add(join(TEMPLATES_ROOT, "maggiedash"), join(root, "templates", "maggiedash"));
343
+ return pairs;
344
+ }
345
+
346
+ function managedFileDigests(root, roots, skills, previousManagedFiles = {}) {
347
+ const managedFiles = {};
348
+ for (const [source, target] of managedPairs(root, roots, skills)) collectManagedFiles(source, target, root, managedFiles, previousManagedFiles);
349
+ return Object.fromEntries(Object.entries(managedFiles).sort(([a], [b]) => a.localeCompare(b)));
350
+ }
351
+
352
+ function readInstallManifest(root) {
353
+ try {
354
+ const path = join(root, STATE_DIR, "install.json");
355
+ return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : {};
356
+ } catch {
357
+ return {};
358
+ }
359
+ }
360
+
361
+ function writeInstallManifest(root, data) {
362
+ const stateDir = join(root, STATE_DIR);
363
+ mkdirSync(stateDir, { recursive: true });
364
+ writeFileSync(join(stateDir, "install.json"), JSON.stringify(data, null, 2) + "\n");
365
+ }
366
+
310
367
  function install(args) {
311
368
  const root = projectRoot(args);
312
369
  const skills = selectedSkills(args);
@@ -336,9 +393,7 @@ function install(args) {
336
393
  if (existsSync(MARKETPLACE_ROOT)) copyIfMissing(MARKETPLACE_ROOT, join(root, "marketplace"));
337
394
  if (existsSync(CONTRACTS_ROOT)) copyIfMissing(CONTRACTS_ROOT, join(root, "contracts"));
338
395
  if (existsSync(join(TEMPLATES_ROOT, "maggiedash"))) copyIfMissing(join(TEMPLATES_ROOT, "maggiedash"), join(root, "templates", "maggiedash"));
339
- const stateDir = join(root, ".maggie");
340
- mkdirSync(stateDir, { recursive: true });
341
- writeFileSync(join(stateDir, "install.json"), JSON.stringify({ version: PACKAGE_VERSION, agents: roots.map((item) => item.slice(root.length + 1)), skills: mergedSkillNames(root, skills), installed_at: new Date().toISOString() }, null, 2) + "\n");
396
+ writeInstallManifest(root, { version: PACKAGE_VERSION, agents: roots.map((item) => item.slice(root.length + 1)), skills: mergedSkillNames(root, skills), managedFiles: managedFileDigests(root, roots, skills), installed_at: new Date().toISOString() });
342
397
  console.log(`Maggie installed in ${root}`);
343
398
  console.log("Run `maggie doctor --project .` before using mutating workflows.");
344
399
  }
@@ -348,6 +403,8 @@ function update(args) {
348
403
  const skills = selectedSkills(args);
349
404
  const force = args.includes("--force");
350
405
  const roots = agentRoots(args, root);
406
+ const previousManifest = readInstallManifest(root);
407
+ const previousManagedFiles = previousManifest.managedFiles && typeof previousManifest.managedFiles === "object" ? previousManifest.managedFiles : {};
351
408
  if (!existsSync(SKILLS_ROOT)) throw new Error("bundled skills are missing; run npm pack from the package source");
352
409
  let updated = 0;
353
410
  for (const agentRoot of roots) {
@@ -358,24 +415,22 @@ function update(args) {
358
415
  console.log(`absent ${target} (run install to add it)`);
359
416
  continue;
360
417
  }
361
- updated += syncTree(source, target, force);
418
+ updated += syncTree(source, target, force, root, previousManagedFiles);
362
419
  }
363
- if (existsSync(REFERENCES_ROOT) && existsSync(join(agentRoot, "references"))) updated += syncTree(REFERENCES_ROOT, join(agentRoot, "references"), force);
420
+ if (existsSync(REFERENCES_ROOT) && existsSync(join(agentRoot, "references"))) updated += syncTree(REFERENCES_ROOT, join(agentRoot, "references"), force, root, previousManagedFiles);
364
421
  }
365
422
  const tools = join(root, "tools");
366
423
  if (existsSync(TOOLS_ROOT)) for (const group of ["clis", "runtime", "integrations"]) if (existsSync(join(TOOLS_ROOT, group))) {
367
424
  const target = join(tools, group);
368
- if (existsSync(target)) updated += syncTree(join(TOOLS_ROOT, group), target, force);
425
+ if (existsSync(target)) updated += syncTree(join(TOOLS_ROOT, group), target, force, root, previousManagedFiles);
369
426
  }
370
427
  if (existsSync(join(DESIGN_ROOT, "SPA-DESIGN.md")) && existsSync(join(root, ".maggie", "design-reference"))) {
371
- updated += syncTree(DESIGN_ROOT, join(root, ".maggie", "design-reference"), force);
428
+ updated += syncTree(DESIGN_ROOT, join(root, ".maggie", "design-reference"), force, root, previousManagedFiles);
372
429
  }
373
- if (existsSync(MARKETPLACE_ROOT) && existsSync(join(root, "marketplace"))) updated += syncTree(MARKETPLACE_ROOT, join(root, "marketplace"), force);
374
- if (existsSync(CONTRACTS_ROOT) && existsSync(join(root, "contracts"))) updated += syncTree(CONTRACTS_ROOT, join(root, "contracts"), force);
375
- if (existsSync(join(TEMPLATES_ROOT, "maggiedash")) && existsSync(join(root, "templates", "maggiedash"))) updated += syncTree(join(TEMPLATES_ROOT, "maggiedash"), join(root, "templates", "maggiedash"), force);
376
- const stateDir = join(root, STATE_DIR);
377
- mkdirSync(stateDir, { recursive: true });
378
- writeFileSync(join(stateDir, "install.json"), JSON.stringify({ version: PACKAGE_VERSION, agents: roots.map((item) => item.slice(root.length + 1)), skills: mergedSkillNames(root, skills), updated_at: new Date().toISOString() }, null, 2) + "\n");
430
+ if (existsSync(MARKETPLACE_ROOT) && existsSync(join(root, "marketplace"))) updated += syncTree(MARKETPLACE_ROOT, join(root, "marketplace"), force, root, previousManagedFiles);
431
+ if (existsSync(CONTRACTS_ROOT) && existsSync(join(root, "contracts"))) updated += syncTree(CONTRACTS_ROOT, join(root, "contracts"), force, root, previousManagedFiles);
432
+ if (existsSync(join(TEMPLATES_ROOT, "maggiedash")) && existsSync(join(root, "templates", "maggiedash"))) updated += syncTree(join(TEMPLATES_ROOT, "maggiedash"), join(root, "templates", "maggiedash"), force, root, previousManagedFiles);
433
+ writeInstallManifest(root, { version: PACKAGE_VERSION, agents: roots.map((item) => item.slice(root.length + 1)), skills: mergedSkillNames(root, skills), managedFiles: managedFileDigests(root, roots, skills, previousManagedFiles), updated_at: new Date().toISOString() });
379
434
  console.log(`Maggie update complete: ${updated} files changed`);
380
435
  if (!force) console.log("Local files with changes were preserved. Review the output and rerun with --force only when replacement is intended.");
381
436
  }
@@ -0,0 +1,29 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://maggie.noblox.app/contracts/maggie-seo/model-policy-v2.schema.json",
4
+ "title": "Maggie provider-routed Gemini model policy v2",
5
+ "type": "object",
6
+ "required": ["policyVersion", "model", "fallbackModel", "promptVersion", "schemaVersion", "locale", "sourceRevision", "providerRoute"],
7
+ "properties": {
8
+ "policyVersion": {"const": "maggie-gemini-model-policy.v2"},
9
+ "model": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{1,99}$"},
10
+ "fallbackModel": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{1,99}$"},
11
+ "promptVersion": {"type": "string", "minLength": 1},
12
+ "schemaVersion": {"type": "string", "minLength": 1},
13
+ "locale": {"type": ["string", "null"]},
14
+ "sourceRevision": {"type": ["string", "null"]},
15
+ "providerRoute": {
16
+ "type": "object",
17
+ "required": ["provider", "endpointEnv", "apiKeyEnv", "billingMode", "maxTokens"],
18
+ "properties": {
19
+ "provider": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{1,63}$"},
20
+ "endpointEnv": {"type": "string", "pattern": "^[A-Z][A-Z0-9_]{2,63}$"},
21
+ "apiKeyEnv": {"type": "string", "pattern": "^[A-Z][A-Z0-9_]{2,63}$"},
22
+ "billingMode": {"enum": ["byok", "managed"]},
23
+ "maxTokens": {"type": "integer", "minimum": 1, "maximum": 32768}
24
+ },
25
+ "additionalProperties": false
26
+ }
27
+ },
28
+ "additionalProperties": false
29
+ }
@@ -0,0 +1,29 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://maggie.noblox.app/contracts/maggie-service-booking/notification-lifecycle-v1.schema.json",
4
+ "title": "Maggie booking notification lifecycle v1",
5
+ "type": "object",
6
+ "required": ["schemaVersion", "bookingId", "state", "events"],
7
+ "properties": {
8
+ "schemaVersion": {"const": "maggie-booking-notification-lifecycle.v1"},
9
+ "bookingId": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$"},
10
+ "state": {"enum": ["received", "pending-confirmation", "confirmed", "alternative-proposed", "cancelled", "failed"]},
11
+ "events": {
12
+ "type": "array",
13
+ "items": {
14
+ "type": "object",
15
+ "required": ["eventId", "type", "occurredAt", "idempotencyKey", "status", "recipientRole"],
16
+ "properties": {
17
+ "eventId": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$"},
18
+ "type": {"enum": ["acknowledgement", "studio-notification", "confirmation", "alternative", "cancellation"]},
19
+ "occurredAt": {"type": "string", "format": "date-time"},
20
+ "idempotencyKey": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$"},
21
+ "status": {"enum": ["pending", "sent", "failed", "suppressed"]},
22
+ "recipientRole": {"enum": ["customer", "studio"]}
23
+ },
24
+ "additionalProperties": false
25
+ }
26
+ }
27
+ },
28
+ "additionalProperties": false
29
+ }
@@ -51,6 +51,12 @@ project-specific design, service, SEO, operations, and deployment adapters.
51
51
  9. Run build, route, SEO, accessibility, content, and deployment checks; hand
52
52
  private content operations to `maggie-ops`.
53
53
 
54
+ After a build, run `maggie doctor PROJECT --rendered-dir dist` (or point it at
55
+ the host's equivalent output directory). The placeholder gate prefers shipped
56
+ HTML/assets and excludes server fixtures, API handlers, and CLI examples; this
57
+ prevents a sample URL used in private preview code from failing public-site
58
+ metadata validation.
59
+
54
60
  For an installed dashboard, create sanitized runtime observations for every
55
61
  endpoint in `maggiedash/contracts/host-adapter-v2.json` and run:
56
62
 
@@ -84,6 +84,21 @@ a successful `assetStatus` (2xx), and a non-empty accessible name. This
84
84
  gate is independent from typecheck/build and is read-only apart from its
85
85
  explicit output.
86
86
 
87
+ ## Hero video playback contract
88
+
89
+ Hero video is an enhancement, not the only way to see the first-band message.
90
+ For every video hero, capture desktop, tablet, and mobile evidence with the
91
+ actual browser adapter. The contract must show a visible poster/fallback before
92
+ `canplaythrough`, a usable play control when autoplay is unavailable, a
93
+ reduced-motion path that does not autoplay, and a `Save-Data`/low-bandwidth
94
+ fallback. Mobile evidence must not assume that `preload="none"` will emit
95
+ `canplaythrough` without user interaction. Record poster visibility, playback
96
+ state, console errors, network errors, and a screenshot per viewport.
97
+
98
+ This contract is provider-neutral. It does not claim that Maggie can generate
99
+ video assets; generated-video planning, continuation, safety review, retries,
100
+ and cost approval remain separate product/provider work.
101
+
87
102
  ## Automatic memory hook
88
103
 
89
104
  Follow [Maggie Memory Hook](../../references/memory-hook.md) at invocation and completion.
@@ -10,7 +10,9 @@ metadata:
10
10
  ## Versioned Gemini policy
11
11
 
12
12
  AI-assisted SEO, localization, and content generation must resolve models
13
- through one explicit policy: `maggie-gemini-model-policy.v1`. Pin the primary
13
+ through one explicit policy: `maggie-gemini-model-policy.v1` for legacy local
14
+ routes or `contracts/maggie-seo/model-policy-v2.schema.json` for provider-routed
15
+ work. Pin the primary
14
16
  model, declare a separate fallback, and record the policy version, exact
15
17
  prompt/schema versions, locale, and source revision with generated evidence.
16
18
  Only model availability failures (retired model, quota, timeout, or provider
@@ -18,6 +20,13 @@ Only model availability failures (retired model, quota, timeout, or provider
18
20
  invalid output remain errors. Do not scatter retired model defaults across
19
21
  routes.
20
22
 
23
+ Provider-routed work must use policy v2 and declare the provider, endpoint
24
+ environment variable, API-key environment variable, billing mode (`byok` or
25
+ `managed`), and a finite `maxTokens` limit. A route is not considered BYOK
26
+ merely because a key exists in the process; the selected endpoint and billing
27
+ path must be recorded in the policy evidence. Never print key values or send a
28
+ provider secret in a report.
29
+
21
30
  ## Static audit and sitemap evidence
22
31
 
23
32
  Run `maggie site-audit https://example.com --crawl --json` for sitemap-listed
@@ -129,6 +138,13 @@ maggie site-audit https://example.com --crawl --json \
129
138
  --reason 'https://example.com/pricing=approved pricing refresh'
130
139
  ```
131
140
 
141
+ When the same approved reason applies to every drift, use `--reason-all` or
142
+ `--reason '*=approved site-wide stylesheet rebuild'`. A URL-specific reason can
143
+ still override the site-wide reason. If a page changes between its first and
144
+ second fetch, the comparison reports it as `unstable` rather than silently
145
+ calling it a content or markup change; review that page and supply a reason
146
+ before recapture.
147
+
132
148
  The command fails closed when the current crawl is incomplete, a drift has no
133
149
  reason, a reason names a URL without a reported drift, or the destination file
134
150
  already exists. The new contract records its `baselineId`, `supersedes` value,
@@ -249,7 +265,9 @@ accepts JPEG, PNG, GIF, and ICO responses. It reports missing, unreachable,
249
265
  undersized, unreadable, or unsupported images and warns when one source is
250
266
  used for at least 75% of a sample of three or more pages. Page-specific
251
267
  first-band images remain an editorial choice and should be declared by the
252
- host manifest.
268
+ host manifest. When `docs/social-cards-pages.json` or
269
+ `docs/public-urls.json` exists, `maggie release` includes the social-card audit
270
+ in its read-only release path.
253
271
 
254
272
  Head-tag auditing consumes a host-produced JSON manifest with `url`, `shell`,
255
273
  and normalized `tags`. It compares structural declarations for icons,
@@ -281,6 +299,10 @@ is intended; the report then says `"declared-origin"`.
281
299
  Use `--output <project>/docs/site-audit.json` to persist the evidence used by
282
300
  the release review.
283
301
 
302
+ For a full crawl, add `--check-internal-links` to follow same-origin HTML links
303
+ and check HTTP 200 targets plus `#fragment` IDs. External, mailto, and other
304
+ non-site links are intentionally out of scope.
305
+
284
306
  - **Plan:** combine Project Context, GSC/search signals, competitor gaps, and
285
307
  AI visibility prompts into topic clusters and briefs.
286
308
  - **Audit:** inspect robots, sitemap, canonical URLs, metadata, JSON-LD,
@@ -186,6 +186,25 @@ evidence artifact and validates the selected ending (`pending`, `redirect`,
186
186
  signals, and sitemap exclusion. It never stores response bodies or decides a
187
187
  redirect destination for the host.
188
188
 
189
+ ## Appointment time contract
190
+
191
+ Any booking or seasonal variant that receives a timezone-naive wall-clock
192
+ timestamp must carry an IANA `venueTimezone` such as `Europe/London`. The
193
+ shared parser in `tools/clis/maggie_service_booking.py` applies venue DST rules
194
+ instead of the server timezone. An ambiguous DST time must also declare
195
+ `venueTimeFold: 0` or `1`; nonexistent local times are rejected. Prefer an
196
+ offset-bearing timestamp when the provider already supplies one. This is a
197
+ validation contract only: it does not claim that a provider supports booking
198
+ creation or confirmation.
199
+
200
+ When a host implements its own notification adapter, use
201
+ [`notification-lifecycle-v1.schema.json`](../../bundled-contracts/maggie-service-booking/notification-lifecycle-v1.schema.json)
202
+ to represent acknowledgement, studio notification, confirmation, alternative,
203
+ and cancellation events. Every event needs an idempotency key and explicit
204
+ status. A request must remain `pending-confirmation` until the host/provider
205
+ actually confirms it; an email or UI receipt must never imply an appointment
206
+ was booked. The contract is provider-neutral and does not send mail by itself.
207
+
189
208
  ## Asynchronous onboarding contract
190
209
 
191
210
  Provider onboarding is three independent state machines: `import` owns the
@@ -88,6 +88,10 @@ with `WORDPRESS_API_TOKEN` or a server-only application-password pair.
88
88
  The starter includes a Tailwind v4 Vite entry point for projects that choose
89
89
  Tailwind during bootstrap. It remains compatible with the existing contract
90
90
  surface and does not require replacing the host's design system.
91
+ The entry point explicitly scopes Tailwind v4 scanning to renderable Astro
92
+ pages, layouts, and components. API/lib fixtures and prose in server-only files
93
+ are not treated as utility-class sources; keep that scope when adding a host
94
+ component.
91
95
 
92
96
  `npm run maggie:*` uses the installed `maggie` binary. It is portable after the
93
97
  starter is copied out of this monorepo; it no longer depends on repository-
@@ -1,4 +1,10 @@
1
1
  @import "tailwindcss";
2
2
 
3
+ /* Scan renderable Astro surfaces only. Server fixtures and prose in API/lib
4
+ files must not become accidental utility classes in the shipped bundle. */
5
+ @source "../pages/**/*.astro";
6
+ @source "../layouts/**/*.astro";
7
+ @source "../components/**/*.{astro,jsx,tsx}";
8
+
3
9
  /* The starter keeps its contract UI framework-neutral while providing a
4
10
  working Tailwind v4 entry point for projects that select Tailwind. */
@@ -23,6 +23,7 @@ STATE_FILE = "bootstrap-state.json"
23
23
  IGNORED_DIRS = {".git", ".next", "node_modules", "dist", "build", "coverage", "generated"}
24
24
  TEXT_SUFFIXES = {".css", ".html", ".js", ".jsx", ".md", ".mdx", ".svelte", ".ts", ".tsx", ".vue", ".astro", ".njk", ".liquid", ".json", ".yaml", ".yml"}
25
25
  DOCTOR_NON_SITE_DIRS = {".agents", ".claude", "__tests__", "contracts", "docs", "examples", "fixtures", "marketplace", "references", "scripts", "test", "tests", "tools"}
26
+ DOCTOR_SERVER_DIRS = {"api", "data", "lib", "server", "services"}
26
27
  WORKFLOW_PHASES = (
27
28
  "maggiedash-foundation",
28
29
  "project-research",
@@ -81,6 +82,26 @@ def doctor_source_files(root: Path, files: list[Path]) -> list[Path]:
81
82
  return result
82
83
 
83
84
 
85
+ def doctor_placeholder_files(root: Path, source_files: list[Path], rendered_dir: str | None = None) -> tuple[list[Path], str]:
86
+ """Choose rendered output first, with a narrow public-source fallback.
87
+
88
+ Server fixtures and CLI examples are not shipped page metadata and must not
89
+ fail a public-site placeholder check. A build output directory is the
90
+ strongest evidence; ``--rendered-dir`` makes that choice explicit for CI.
91
+ """
92
+ candidates = [Path(rendered_dir).expanduser().resolve()] if rendered_dir else [root / name for name in ("dist", "build", "out", "public")]
93
+ for candidate in candidates:
94
+ if not candidate.is_dir():
95
+ continue
96
+ files = sorted(path for path in candidate.rglob("*") if path.is_file() and path.suffix.lower() in TEXT_SUFFIXES)
97
+ return files, "rendered-output"
98
+ fallback = [
99
+ path for path in source_files
100
+ if not any(part.lower() in DOCTOR_SERVER_DIRS for part in path.relative_to(root).parts[:-1])
101
+ ]
102
+ return fallback, "public-site-source"
103
+
104
+
84
105
  def package_info(root: Path) -> tuple[dict, Path | None]:
85
106
  candidates = [root / "package.json"] + sorted(path for path in root.glob("*/package.json") if path.parent.name not in IGNORED_DIRS)
86
107
  parsed: list[tuple[int, dict, Path]] = []
@@ -819,6 +840,8 @@ def command_doctor(args: argparse.Namespace) -> int:
819
840
  files = list(files_for(root))
820
841
  source_files = doctor_source_files(root, files)
821
842
  text = "\n".join(read_text(path) for path in source_files)
843
+ placeholder_files, placeholder_scope = doctor_placeholder_files(root, source_files, getattr(args, "rendered_dir", None))
844
+ placeholder_text = "\n".join(read_text(path) for path in placeholder_files)
822
845
  paths = [evidence(path, root).lower() for path in source_files]
823
846
 
824
847
  checks = {
@@ -838,7 +861,7 @@ def command_doctor(args: argparse.Namespace) -> int:
838
861
  "jsonld_dates": "datePublished" in text and ("dateModified" in text or "updatedAt" in text),
839
862
  "published_only_guard": "published" in text.lower() and any(token in text.lower() for token in {"sitemap", "post"}),
840
863
  "no_public_api_key": not bool(re.search(r"(?:PUBLIC|NEXT_PUBLIC)[A-Z0-9_]*(?:API|KEY|TOKEN)", text, re.IGNORECASE)),
841
- "no_placeholder_metadata": not any(token in text.lower() for token in {"your-domain.com", "example.com", "lorem ipsum", "replace-me"}),
864
+ "no_placeholder_metadata": not any(token in placeholder_text.lower() for token in {"your-domain.com", "example.com", "lorem ipsum", "replace-me"}),
842
865
  }
843
866
  if args.strict:
844
867
  checks.update({
@@ -886,7 +909,7 @@ def command_doctor(args: argparse.Namespace) -> int:
886
909
  safe_skips.append("robots route requires framework confirmation")
887
910
  for check, reason in (("canonical_metadata", "canonical metadata needs a site-specific URL"), ("meta_description", "metadata content needs editorial input"), ("article_jsonld", "JSON-LD shape needs content-specific fields")):
888
911
  if not checks[check]: safe_skips.append(reason)
889
- result = {"project_root": str(root), "placeholder_scan": {"scope": "public-site-source", "files": len(source_files)}, "checks": {key: {"ok": value} for key, value in checks.items()}, "passed": all(checks.values())}
912
+ result = {"project_root": str(root), "placeholder_scan": {"scope": placeholder_scope, "files": len(placeholder_files)}, "checks": {key: {"ok": value} for key, value in checks.items()}, "passed": all(checks.values())}
890
913
  if getattr(args, "fix_safe", False): result["safe_fixes"] = {"applied": safe_fixes, "skipped_requires_confirmation": safe_skips}
891
914
  print(json.dumps(result, indent=2))
892
915
  return 0 if result["passed"] else 1
@@ -1000,6 +1023,7 @@ def parser() -> argparse.ArgumentParser:
1000
1023
  doctor.add_argument("--strict", action="store_true", help="also require the complete AI-native blog and Ops surface")
1001
1024
  doctor.add_argument("--ci", action="store_true", help="CI alias for strict checks")
1002
1025
  doctor.add_argument("--fix-safe", action="store_true", help="apply only low-risk route fixes; never changes schema, slugs, canonical URLs or publishing state")
1026
+ doctor.add_argument("--rendered-dir", help="scan this build output for placeholder metadata instead of source fixtures")
1003
1027
  doctor.set_defaults(func=command_doctor)
1004
1028
  for name in ("routes", "schema", "seo", "migration", "env"):
1005
1029
  check = sub.add_parser(name, help=f"run Maggie {name} contract checks")
@@ -320,6 +320,29 @@ def editorial_gate(project: Path) -> dict:
320
320
  }
321
321
 
322
322
 
323
+ def social_card_gate(project: Path) -> dict:
324
+ """Include the per-page social-card audit whenever a manifest is present."""
325
+ candidates = (
326
+ (project / "docs" / "social-cards-pages.json", "--pages-file"),
327
+ (project / ".maggie" / "social-cards-pages.json", "--pages-file"),
328
+ (project / "docs" / "public-urls.json", "--urls-file"),
329
+ (project / ".maggie" / "public-urls.json", "--urls-file"),
330
+ )
331
+ candidate = next(((path, flag) for path, flag in candidates if path.is_file()), None)
332
+ if candidate is None:
333
+ return {
334
+ "name": "social-card-audit",
335
+ "passed": True,
336
+ "exitCode": 0,
337
+ "result": {"passed": True, "state": "not-configured", "reason": "add docs/social-cards-pages.json or docs/public-urls.json to enable the release-path audit"},
338
+ "stderr": "",
339
+ }
340
+ path, flag = candidate
341
+ result = run_gate("social-card-audit", [sys.executable, str(ROOT / "maggie_social_cards.py"), "audit", flag, str(path)], project)
342
+ result["result"]["manifest"] = str(path.relative_to(project))
343
+ return result
344
+
345
+
323
346
  def runtime_smoke(base_url: str) -> dict:
324
347
  """Check basic public, admin redirect, and unknown-route behaviour."""
325
348
  base = base_url.rstrip("/") + "/"
@@ -366,6 +389,7 @@ def main() -> int:
366
389
  gates.append(changed_surface_gate(project))
367
390
  gates.append(qa_gate(project, args.environment, args.base_url))
368
391
  gates.append(build_gate(project))
392
+ gates.append(social_card_gate(project))
369
393
  for name, command in build_gates(project, args.environment, args.target, not args.skip_compatibility):
370
394
  gates.append(run_gate(name, command, project))
371
395
  if args.analytics_release_gate:
@@ -7,6 +7,7 @@ import xml.etree.ElementTree as ET
7
7
  from datetime import datetime, timezone
8
8
  from html.parser import HTMLParser
9
9
  from pathlib import Path
10
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
10
11
  from urllib.parse import quote, urljoin, urlsplit, urlparse
11
12
  from urllib.request import Request, urlopen
12
13
 
@@ -20,6 +21,39 @@ DURATION = re.compile(r"(\d{2,3})\s*(?:min|mins|minutes?)", re.I)
20
21
  SUPPLY_STATES = {"live", "withdrawn"}
21
22
  DISPLAY_STATES = {"published", "hidden", "retired"}
22
23
 
24
+
25
+ def parse_booking_time(value, venue_timezone=None, venue_time_fold=None):
26
+ """Parse an appointment time using venue wall-clock rules, not server TZ."""
27
+ raw = str(value or "").strip()
28
+ parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
29
+ if parsed.tzinfo is None:
30
+ if not venue_timezone:
31
+ raise ValueError("venueTimezone is required for a timezone-naive booking time")
32
+ try:
33
+ zone = ZoneInfo(str(venue_timezone))
34
+ except ZoneInfoNotFoundError as exc:
35
+ raise ValueError("venueTimezone is not an installed IANA timezone") from exc
36
+ candidates = []
37
+ for fold in (0, 1):
38
+ candidate = parsed.replace(tzinfo=zone, fold=fold)
39
+ round_trip = candidate.astimezone(timezone.utc).astimezone(zone).replace(tzinfo=None)
40
+ if round_trip == parsed:
41
+ candidates.append(candidate)
42
+ if not candidates:
43
+ raise ValueError("venue wall-clock time does not exist because of a DST transition")
44
+ if len({candidate.utcoffset() for candidate in candidates}) > 1:
45
+ if venue_time_fold not in (0, 1):
46
+ raise ValueError("venueTimeFold 0 or 1 is required for an ambiguous DST time")
47
+ parsed = parsed.replace(tzinfo=zone, fold=venue_time_fold)
48
+ else:
49
+ parsed = candidates[0]
50
+ elif venue_timezone:
51
+ try:
52
+ parsed = parsed.astimezone(ZoneInfo(str(venue_timezone)))
53
+ except ZoneInfoNotFoundError as exc:
54
+ raise ValueError("venueTimezone is not an installed IANA timezone") from exc
55
+ return parsed
56
+
23
57
  def slug(value):
24
58
  value = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
25
59
  return value or "service"
@@ -1081,8 +1115,10 @@ def cmd_variant_audit(args):
1081
1115
  if typ=="event" and not v.get("eventId"): errors.append(f"{prefix}: eventId is required")
1082
1116
  if typ=="promotion" and not v.get("promotionId"): errors.append(f"{prefix}: promotionId is required")
1083
1117
  if typ in {"holiday","seasonal","event","promotion"}:
1084
- try: start=datetime.fromisoformat(str(v.get("startsAt","")).replace("Z","+00:00")); end=datetime.fromisoformat(str(v.get("endsAt","")).replace("Z","+00:00"))
1085
- except ValueError: errors.append(f"{prefix}: startsAt and endsAt are required ISO timestamps"); start=end=None
1118
+ venue_timezone = v.get("venueTimezone") or (payload.get("venueTimezone") if isinstance(payload, dict) else None)
1119
+ fold = v.get("venueTimeFold") if "venueTimeFold" in v else (payload.get("venueTimeFold") if isinstance(payload, dict) else None)
1120
+ try: start=parse_booking_time(v.get("startsAt"), venue_timezone, fold); end=parse_booking_time(v.get("endsAt"), venue_timezone, fold)
1121
+ except ValueError as exc: errors.append(f"{prefix}: startsAt and endsAt require venue wall-clock timezone ({exc})"); start=end=None
1086
1122
  if start and end and end<=start: errors.append(f"{prefix}: endsAt must be after startsAt")
1087
1123
  if end and end<=as_of and v.get("indexable") is True: errors.append(f"{prefix}: expired variant cannot be indexable")
1088
1124
  indexable=v.get("indexable") is True; noindex=v.get("noIndex") is True
@@ -26,7 +26,10 @@ def stable_structure_reference(key: str, value: str) -> str:
26
26
  path = parsed.path
27
27
  if not re.search(r"\.(?:css|js|mjs|png|jpe?g|gif|webp|avif|svg|ico|woff2?|ttf|otf)$", path, re.I):
28
28
  return value
29
- path = re.sub(r"(?:[-_.])[0-9a-f]{8,64}(?=\.[^.]+$)", "-asset", path, flags=re.I)
29
+ # Vite/Astro and similar bundlers also emit base64url-safe digests such as
30
+ # ``SiteLayout.Bdp0itNn.css``. Restrict this to a digest-looking token
31
+ # immediately before a known asset extension.
32
+ path = re.sub(r"(?:[-_.])[A-Za-z0-9_-]{8,64}(?=\.[^.]+$)", "-asset", path)
30
33
  return urlunparse((parsed.scheme, parsed.netloc, path, parsed.params, "", ""))
31
34
 
32
35
 
@@ -65,9 +68,14 @@ class PageParser(HTMLParser):
65
68
  self.headings = []
66
69
  self.paragraphs = []
67
70
  self.links = []
71
+ self.fragment_ids = set()
68
72
 
69
73
  def handle_starttag(self, tag, attrs):
70
74
  data = dict(attrs)
75
+ if data.get("id"):
76
+ self.fragment_ids.add(data["id"])
77
+ if tag == "a" and data.get("name"):
78
+ self.fragment_ids.add(data["name"])
71
79
  if data.get("data-template"):
72
80
  self.templates.add(data["data-template"])
73
81
  if tag in {"script", "style", "noscript"}:
@@ -184,6 +192,7 @@ def audit_page(url: str, html: str, status: int, content_type: str, expected_lan
184
192
  "title": page.title, "meta": page.meta, "canonical": canonical,
185
193
  "lang": page.lang, "hreflang": page.hreflang, "robots": robots,
186
194
  "jsonld": page.jsonld_values, "images": page.images,
195
+ "fragmentIds": sorted(page.fragment_ids),
187
196
  "structureHash": hashlib.sha256(json.dumps(stable_structure(page.structure), sort_keys=True).encode()).hexdigest(),
188
197
  "textHash": hashlib.sha256(" ".join(page.visible_text).encode()).hexdigest(),
189
198
  },
@@ -298,6 +307,99 @@ def access_log_sitemap_check(path: Path, sitemap_path: str = "/sitemap.xml") ->
298
307
  return {"provided": True, "path": str(path), "requestCount": len(requests), "googlebotRequestCount": len(googlebot), "status": "requested" if requests else "not-requested", "diagnosis": "googlebot-request-observed" if googlebot else ("other-client-request-only" if requests else "no-matching-request")}
299
308
 
300
309
 
310
+ def internal_link_check(base: str, pages: list[dict], fetcher=fetch) -> dict[str, object]:
311
+ """Validate same-origin links and fragment targets discovered during a crawl."""
312
+ origin = urlparse(base)
313
+ page_by_url = {page.get("url"): page for page in pages if page.get("url")}
314
+ errors: list[dict[str, str]] = []
315
+ checked = 0
316
+ seen: set[tuple[str, str]] = set()
317
+ for page in pages:
318
+ page_url = str(page.get("url") or "")
319
+ for link in (page.get("contentContract") or {}).get("links", []):
320
+ target = urljoin(page_url, str(link.get("href") or ""))
321
+ parsed = urlparse(target)
322
+ if parsed.scheme not in {"http", "https"} or (parsed.scheme, parsed.netloc.lower()) != (origin.scheme, origin.netloc.lower()):
323
+ continue
324
+ fragment = parsed.fragment
325
+ target_url = urlunparse((parsed.scheme, parsed.netloc, parsed.path or "/", parsed.params, parsed.query, ""))
326
+ key = (target_url, fragment)
327
+ if key in seen:
328
+ continue
329
+ seen.add(key)
330
+ checked += 1
331
+ target_page = page_by_url.get(target_url)
332
+ if target_page and isinstance(target_page.get("contract"), dict):
333
+ status = target_page.get("status")
334
+ content_type = target_page.get("content_type")
335
+ fragment_ids = set(target_page["contract"].get("fragmentIds", []))
336
+ else:
337
+ try:
338
+ status, content_type, body = fetcher(target_url)
339
+ parsed_target = PageParser()
340
+ parsed_target.feed(body)
341
+ fragment_ids = parsed_target.fragment_ids
342
+ except Exception as exc:
343
+ errors.append({"source": page_url, "url": target_url, "reason": type(exc).__name__})
344
+ continue
345
+ if status != 200 or content_type != "text/html":
346
+ errors.append({"source": page_url, "url": target_url, "reason": "target did not return HTTP 200 HTML"})
347
+ elif fragment and fragment not in fragment_ids:
348
+ errors.append({"source": page_url, "url": target, "reason": "fragment target not found"})
349
+ return {"ok": not errors, "linksChecked": checked, "errors": errors}
350
+
351
+
352
+ def mark_unstable_pages(pages: list[dict]) -> None:
353
+ """Refetch baseline candidates once and label per-response markup drift."""
354
+ for page in pages:
355
+ if not isinstance(page.get("contract"), dict):
356
+ continue
357
+ try:
358
+ status, content_type, body = fetch(page["url"])
359
+ second = audit_page(page["url"], body, status, content_type)
360
+ first_contract = page["contract"]
361
+ second_contract = second["contract"]
362
+ first_content = page.get("contentContract", {})
363
+ second_content = second.get("contentContract", {})
364
+ changed = any(first_contract.get(key) != second_contract.get(key) for key in ("structureHash", "textHash")) or first_content.get("contentHash") != second_content.get("contentHash")
365
+ page["stability"] = {
366
+ "status": "unstable" if changed else "stable",
367
+ "changedBetweenFetches": changed,
368
+ "secondStructureHash": second_contract.get("structureHash"),
369
+ "secondTextHash": second_contract.get("textHash"),
370
+ }
371
+ except Exception as exc:
372
+ page["stability"] = {"status": "recapture-failed", "changedBetweenFetches": False, "error": type(exc).__name__}
373
+
374
+
375
+ def parse_baseline_reasons(values: list[str], drift_urls: set[str], reason_all: str | None = None) -> dict[str, str]:
376
+ """Parse per-URL and site-wide reviewer reasons with actionable errors."""
377
+ reasons: dict[str, str] = {}
378
+ default = reason_all.strip() if reason_all and reason_all.strip() else ""
379
+ for value in values:
380
+ url, separator, reason = value.partition("=")
381
+ if not separator or not url.strip() or not reason.strip():
382
+ raise ValueError("each --reason must use URL=REASON")
383
+ url, reason = url.strip(), reason.strip()
384
+ if url == "*":
385
+ if default:
386
+ raise ValueError("duplicate site-wide baseline change reason")
387
+ default = reason
388
+ continue
389
+ if url in reasons:
390
+ raise ValueError("duplicate baseline change reason: " + url)
391
+ if url not in drift_urls:
392
+ raise ValueError("baseline change reason references a URL without a detected drift: " + url)
393
+ reasons[url] = reason
394
+ if default:
395
+ for url in drift_urls:
396
+ reasons.setdefault(url, default)
397
+ missing_reasons = sorted(drift_urls - reasons.keys())
398
+ if missing_reasons:
399
+ raise ValueError("every drifting URL requires a reviewer reason: " + ", ".join(missing_reasons))
400
+ return reasons
401
+
402
+
301
403
  def main() -> int:
302
404
  parser = argparse.ArgumentParser()
303
405
  parser.add_argument("url")
@@ -309,6 +411,7 @@ def main() -> int:
309
411
  parser.add_argument("--languages", help="comma-separated expected languages/locales, e.g. en-GB,es-MX,ja-JP")
310
412
  parser.add_argument("--markets", help="comma-separated markets: global,uk,us")
311
413
  parser.add_argument("--check-hreflang", action="store_true")
414
+ parser.add_argument("--check-internal-links", action="store_true", help="validate same-origin links and fragment targets during a crawl")
312
415
  parser.add_argument("--check-translation-completeness", action="store_true")
313
416
  parser.add_argument("--access-log", type=Path, help="optional local access log for crawler-request diagnosis")
314
417
  parser.add_argument("--require-sitemap-request", action="store_true", help="fail unless the supplied access log contains a sitemap request")
@@ -319,12 +422,15 @@ def main() -> int:
319
422
  parser.add_argument("--recapture-baseline", type=Path, help="write a reviewed versioned baseline after reviewing every drift")
320
423
  parser.add_argument("--baseline-id", help="required stable ID for --recapture-baseline")
321
424
  parser.add_argument("--reason", action="append", default=[], metavar="URL=REASON", help="reviewer-approved reason for one drifting URL; repeat for every drift")
425
+ parser.add_argument("--reason-all", help="reviewer-approved reason applied to every drifting URL during baseline recapture")
322
426
  parser.add_argument("--reviewer", help="required for --save-baseline")
323
427
  args = parser.parse_args()
324
428
  if args.max_pages < 1:
325
429
  parser.error("max-pages must be positive")
326
430
  if (args.save_baseline or args.baseline) and not args.crawl:
327
431
  parser.error("baseline operations require --crawl")
432
+ if args.check_internal_links and not args.crawl:
433
+ parser.error("internal link checks require --crawl")
328
434
  if args.save_baseline and not (args.reviewer or "").strip():
329
435
  parser.error("save-baseline requires --reviewer")
330
436
  if args.recapture_baseline:
@@ -422,6 +528,9 @@ def main() -> int:
422
528
  crawl["sitemapOriginPolicy"] = "declared-origin" if args.preserve_sitemap_origin else "requested-origin"
423
529
  crawl["passed"] = crawl["complete"] and bool(urls) and len(crawl["pages"]) == len(urls) and all(item["passed"] for item in crawl["pages"])
424
530
  crawl["url_count"] = len(urls)
531
+ if args.check_internal_links:
532
+ crawl["internal_links"] = internal_link_check(base, crawl["pages"])
533
+ crawl["passed"] = crawl["passed"] and crawl["internal_links"]["ok"]
425
534
  except Exception as exc:
426
535
  crawl = {"enabled": True, "passed": False, "error": type(exc).__name__, "pages": []}
427
536
 
@@ -431,6 +540,8 @@ def main() -> int:
431
540
  try:
432
541
  current_passed = result["passed"]
433
542
  if args.baseline:
543
+ if args.crawl:
544
+ mark_unstable_pages(crawl["pages"])
434
545
  previous_baseline = json.loads(args.baseline.read_text())
435
546
  result["baseline"] = site_baseline.compare(previous_baseline, result)
436
547
  result["passed"] = result["passed"] and result["baseline"]["passed"]
@@ -439,20 +550,8 @@ def main() -> int:
439
550
  drift_urls = set(comparison.get("added", [])) | set(comparison.get("removed", []))
440
551
  drift_urls |= {item["url"] for item in comparison.get("changed", []) if isinstance(item, dict) and item.get("url")}
441
552
  drift_urls |= {item["url"] for item in comparison.get("contentChanged", []) if isinstance(item, dict) and item.get("url")}
442
- reasons: dict[str, str] = {}
443
- for value in args.reason:
444
- url, separator, reason = value.partition("=")
445
- if not separator or not url.strip() or not reason.strip():
446
- raise ValueError("each --reason must use URL=REASON")
447
- url = url.strip()
448
- if url in reasons:
449
- raise ValueError("duplicate baseline change reason")
450
- if url not in drift_urls:
451
- raise ValueError("baseline change reason references a URL without a detected drift")
452
- reasons[url] = reason.strip()
453
- missing_reasons = sorted(drift_urls - reasons.keys())
454
- if missing_reasons:
455
- raise ValueError("every drifting URL requires a reviewer reason")
553
+ drift_urls |= set(comparison.get("unstable", []))
554
+ reasons = parse_baseline_reasons(args.reason, drift_urls, args.reason_all)
456
555
  if not current_passed:
457
556
  raise ValueError("baseline recapture requires a passing current crawl")
458
557
  recapture_report = dict(result)
@@ -36,6 +36,8 @@ def snapshot(
36
36
  for page in pages:
37
37
  if not page.get("passed") or not isinstance(page.get("contract"), dict):
38
38
  raise ValueError("baseline requires successful page contracts")
39
+ if (page.get("stability") or {}).get("status") == "unstable":
40
+ raise ValueError("baseline requires stable page contracts")
39
41
  if page["url"] in contracts:
40
42
  raise ValueError("duplicate page URL")
41
43
  if urlparse(page["url"]).query:
@@ -75,11 +77,16 @@ def compare(baseline: dict, report: dict) -> dict:
75
77
  expected = baseline["pages"]
76
78
  added, removed = sorted(current.keys() - expected.keys()), sorted(expected.keys() - current.keys())
77
79
  changes = []
80
+ unstable = []
78
81
  for url in sorted(expected.keys() & current.keys()):
79
82
  actual = current[url]
80
83
  if not isinstance(actual, dict):
81
84
  errors.append("missing page contract: " + url)
82
85
  continue
86
+ page = next((item for item in crawl_pages if item.get("url") == url), {})
87
+ if (page.get("stability") or {}).get("status") == "unstable":
88
+ unstable.append(url)
89
+ continue
83
90
  fields = sorted(key for key in expected[url].keys() | actual.keys() if expected[url].get(key) != actual.get(key))
84
91
  if fields:
85
92
  changes.append({"url": url, "fields": fields})
@@ -88,6 +95,8 @@ def compare(baseline: dict, report: dict) -> dict:
88
95
  content_not_compared = []
89
96
  if expected_content:
90
97
  for url in sorted(expected_content.keys() & current_content.keys()):
98
+ if url in unstable:
99
+ continue
91
100
  expected_contract = expected_content[url]
92
101
  current_contract = current_content[url]
93
102
  ignored_legacy_fields = {"images", "imageAlts"} if "images" not in expected_contract else set()
@@ -99,9 +108,10 @@ def compare(baseline: dict, report: dict) -> dict:
99
108
  missing_content = sorted(expected_content.keys() - current_content.keys())
100
109
  if missing_content:
101
110
  errors.append("missing content contract: " + ", ".join(missing_content))
102
- return {"passed": not errors and not added and not removed and not changes and not content_changes,
111
+ return {"passed": not errors and not added and not removed and not changes and not content_changes and not unstable,
103
112
  "errors": errors, "added": added, "removed": removed, "changed": changes,
104
113
  "contentChanged": content_changes,
114
+ "unstable": unstable,
105
115
  "contentNotCompared": content_not_compared,
106
116
  "excludedQueryUrls": query_urls}
107
117
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topy-ai/maggie",
3
- "version": "0.7.34",
3
+ "version": "0.7.36",
4
4
  "description": "Install and manage Maggie Skills for AI coding agents",
5
5
  "license": "MIT",
6
6
  "type": "module",