@topy-ai/maggie 0.7.27 → 0.7.28
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.md +42 -3
- package/README.zh-TW.md +4 -2
- package/bin/maggie.js +17 -5
- package/bundled-contracts/maggie-deployment/runtime-parity-v1.schema.json +20 -2
- package/bundled-contracts/maggie-seo/model-policy-v1.schema.json +17 -0
- package/bundled-contracts/maggie-service-booking/resolver-v1.schema.json +17 -0
- package/bundled-contracts/maggie-service-booking/worker-health-v1.schema.json +17 -0
- package/bundled-contracts/maggiedash/migration-preflight-v1.schema.json +16 -0
- package/bundled-skills/maggie-dash/SKILL.md +12 -0
- package/bundled-skills/maggie-deployment/SKILL.md +8 -4
- package/bundled-skills/maggie-feedback/SKILL.md +13 -3
- package/bundled-skills/maggie-marketplace/SKILL.md +16 -0
- package/bundled-skills/maggie-seo-geo/SKILL.md +11 -0
- package/bundled-skills/maggie-service-booking/SKILL.md +19 -0
- package/bundled-tools/clis/maggie_deployment_parity.py +42 -0
- package/bundled-tools/clis/maggie_feedback.py +108 -0
- package/bundled-tools/clis/maggie_localization.py +18 -1
- package/bundled-tools/clis/maggie_marketplace.py +46 -0
- package/bundled-tools/clis/maggie_migration.py +43 -0
- package/bundled-tools/clis/maggie_service_booking.py +84 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -60,7 +60,12 @@ 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.
|
|
63
|
+
The current release is `0.7.28`. It adds a versioned Gemini model policy with
|
|
64
|
+
an explicit fallback and provenance, bundled-CLI version discovery, and a
|
|
65
|
+
bundled-first dispatcher that avoids stale project wrappers. It also adds
|
|
66
|
+
privacy-safe feedback batch review/duplicate detection, marketplace
|
|
67
|
+
enrichment evidence, bounded booking worker/resolver reports, and read-only
|
|
68
|
+
migration preflight contracts. The release retains the provider-neutral MaggieDash host
|
|
64
69
|
adapter contract, idempotent PostgreSQL starter schema, type-only backend
|
|
65
70
|
boundary, runtime endpoint conformance evidence, safe quota/error summaries,
|
|
66
71
|
stale marketplace readiness revalidation, explicit VPS port/origin planning,
|
|
@@ -206,6 +211,7 @@ redacted and explicit; it is never promoted to active memory automatically.
|
|
|
206
211
|
The CLI provides the installer plus durable workflow commands:
|
|
207
212
|
|
|
208
213
|
```text
|
|
214
|
+
maggie --version | version
|
|
209
215
|
maggie init | install | update | remove | list | doctor
|
|
210
216
|
maggie cleanup --project . [--confirm]
|
|
211
217
|
maggie bootstrap interview | phase ...
|
|
@@ -226,6 +232,7 @@ maggie clone-to-template ... # URL → validated marketplace template
|
|
|
226
232
|
maggie marketplace ... # catalog and on-demand template workflow
|
|
227
233
|
maggie memory ... # confirmed preferences and lessons
|
|
228
234
|
maggie feedback ... # redact, preview, batch, submit, list
|
|
235
|
+
maggie feedback batch-review ... # aggregate bounded drafts and detect duplicates
|
|
229
236
|
maggie qa ... # scenario browser QA, fix/retest, release gate
|
|
230
237
|
maggie localization ... # plan, validate, review, publish, stale
|
|
231
238
|
maggie service ... # import, sync/report, catalogue, lifecycle, validate
|
|
@@ -240,9 +247,13 @@ maggie deployment | migration | release | analytics | schedule
|
|
|
240
247
|
maggie deployment readiness --project PATH
|
|
241
248
|
maggie deployment parity --project PATH --evidence FILE --output FILE
|
|
242
249
|
maggie migration identity --identity-file FILE [--expected-file FILE]
|
|
250
|
+
maggie migration preflight --evidence FILE [--output FILE]
|
|
243
251
|
maggie deployment canary --asset URL=SHA256 --render-report report.json
|
|
244
252
|
maggie design icon-inventory --source-dir src --runtime assets/icons.css
|
|
245
253
|
maggie api lifecycle | site-audit | ops audit
|
|
254
|
+
maggie marketplace enrichment-report --project PATH --input FILE [--output FILE]
|
|
255
|
+
maggie service worker-health --project PATH --evidence FILE [--output FILE]
|
|
256
|
+
maggie service resolver-audit --project PATH --evidence FILE [--output FILE]
|
|
246
257
|
```
|
|
247
258
|
|
|
248
259
|
Agent content writes must declare the exact host origin that minted the
|
|
@@ -405,8 +416,8 @@ artifact schemas.
|
|
|
405
416
|
Recommended upgrade sequence for the current release:
|
|
406
417
|
|
|
407
418
|
```bash
|
|
408
|
-
npx @topy-ai/maggie@0.7.
|
|
409
|
-
npx @topy-ai/maggie@0.7.
|
|
419
|
+
npx @topy-ai/maggie@0.7.28 update --project . --force
|
|
420
|
+
npx @topy-ai/maggie@0.7.28 cleanup --project .
|
|
410
421
|
```
|
|
411
422
|
|
|
412
423
|
Maintainers should pass npm credentials through the repository helper, never
|
|
@@ -577,6 +588,34 @@ maggie feedback batch --project . --batch-file ./feedback-batch.json
|
|
|
577
588
|
|
|
578
589
|
The batch command never submits automatically or writes active memory.
|
|
579
590
|
|
|
591
|
+
Aggregate a correlated batch before reviewing individual drafts:
|
|
592
|
+
|
|
593
|
+
```bash
|
|
594
|
+
maggie feedback batch-review --project . --batch-id run-review-001
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
The `maggie-feedback-batch-review.v1` report retains bounded observations,
|
|
598
|
+
missing-index and duplicate checks, and privacy flags. It never contains raw
|
|
599
|
+
responses, local paths, or secrets.
|
|
600
|
+
|
|
601
|
+
The same evidence-first pattern is available for the other maintainer checks:
|
|
602
|
+
|
|
603
|
+
```bash
|
|
604
|
+
maggie marketplace enrichment-report --project . \
|
|
605
|
+
--input .maggie/marketplace/enrichment-input.json
|
|
606
|
+
maggie service worker-health --project . \
|
|
607
|
+
--evidence .maggie/booking/worker-health-input.json
|
|
608
|
+
maggie service resolver-audit --project . \
|
|
609
|
+
--evidence .maggie/booking/resolver-input.json
|
|
610
|
+
maggie migration preflight \
|
|
611
|
+
--evidence .maggie/migration/preflight-input.json
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
Each command validates a versioned, bounded evidence contract and writes only
|
|
615
|
+
sanitized status data. The deployment parity report also records the active
|
|
616
|
+
release revision, runtime configuration hash, migration version, worker
|
|
617
|
+
revision, asset fingerprints, and rollback target.
|
|
618
|
+
|
|
580
619
|
Submission is never implicit. Project paths, source files, logs, and secrets
|
|
581
620
|
are excluded by default. The hosted endpoint stores the normalized feedback in
|
|
582
621
|
NoBlox for maintainer review; it does not automatically create a GitHub Issue.
|
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.
|
|
11
|
+
npx @topy-ai/maggie@0.7.28 init --agent all
|
|
12
12
|
npx @topy-ai/maggie doctor --project .
|
|
13
13
|
```
|
|
14
14
|
|
|
@@ -25,7 +25,9 @@ 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.
|
|
28
|
+
目前 release 是 `0.7.28`,加入 versioned Gemini model policy、明確 fallback 與 provenance、bundled-first CLI
|
|
29
|
+
dispatch、`maggie --version`、feedback batch review 聚合與重複偵測、marketplace enrichment evidence、booking
|
|
30
|
+
worker/resolver evidence,以及 read-only migration preflight。它也包含 host-owned mobile app surface contract、signed-in
|
|
29
31
|
camera-state QA、直接 Astro route resolution、correlated feedback batch,以及
|
|
30
32
|
runtime package version capture。
|
|
31
33
|
|
package/bin/maggie.js
CHANGED
|
@@ -51,6 +51,7 @@ function usage() {
|
|
|
51
51
|
console.log(`Maggie Skills installer
|
|
52
52
|
|
|
53
53
|
Usage:
|
|
54
|
+
maggie --version | version
|
|
54
55
|
maggie init [--project PATH] [--agent auto|codex|claude|all] [--skills LIST]
|
|
55
56
|
maggie install [SKILL ...] [--project PATH] [--agent auto|codex|claude|all]
|
|
56
57
|
maggie update [SKILL ...] [--project PATH] [--agent auto|codex|claude|all] [--force]
|
|
@@ -117,6 +118,7 @@ Usage:
|
|
|
117
118
|
maggie deployment readiness --project PATH
|
|
118
119
|
maggie deployment parity --project PATH --evidence FILE --output FILE
|
|
119
120
|
maggie deployment canary --project PATH --asset URL=SHA256 --render-report report.json --output docs/deployment-canary.json
|
|
121
|
+
maggie migration preflight --evidence FILE --output FILE
|
|
120
122
|
maggie migration --project PATH --environment staging
|
|
121
123
|
maggie migration identity --identity-file FILE [--expected-file FILE]
|
|
122
124
|
maggie schedule PATH/.maggie/schedule.json --project PATH
|
|
@@ -126,7 +128,7 @@ Usage:
|
|
|
126
128
|
maggie memory <init|list|search|context|add|record-error|transition|export> --project PATH
|
|
127
129
|
maggie localization <extract|plan|generate|preview|validate|review|publish|stale|glossary> [options]
|
|
128
130
|
maggie seo performance|images|sitemap|indexnow|social-cards|head-tags [options] (sitemap supports strict validate and agent-files)
|
|
129
|
-
maggie feedback <collect|preview|batch|submit|list> [options]
|
|
131
|
+
maggie feedback <collect|preview|batch|batch-review|submit|list> [options]
|
|
130
132
|
maggie qa <start|record|summary|export|assertion-audit> [options]
|
|
131
133
|
maggie site-audit URL [--crawl] [--access-log FILE] [--require-sitemap-request] [--languages en-GB,es-MX,ja-JP] [--check-hreflang]
|
|
132
134
|
maggie site-audit URL --crawl --save-baseline FILE --reviewer NAME
|
|
@@ -294,7 +296,7 @@ function list() {
|
|
|
294
296
|
|
|
295
297
|
function marketplace(args) {
|
|
296
298
|
const root = projectRoot(args);
|
|
297
|
-
const script =
|
|
299
|
+
const script = toolScript("maggie_marketplace.py", root);
|
|
298
300
|
if (!existsSync(script)) throw new Error(`marketplace CLI is missing: ${script}`);
|
|
299
301
|
const command = args[0] || "list";
|
|
300
302
|
const forwarded = args.slice(1);
|
|
@@ -306,7 +308,7 @@ function marketplace(args) {
|
|
|
306
308
|
function service(args) {
|
|
307
309
|
const root = projectRoot(args);
|
|
308
310
|
const scriptName = args[0] === "orchestration-validate" ? "maggie_service_onboarding.py" : "maggie_service_booking.py";
|
|
309
|
-
const script =
|
|
311
|
+
const script = toolScript(scriptName, root);
|
|
310
312
|
if (!existsSync(script)) throw new Error(`service booking CLI is missing: ${script}`);
|
|
311
313
|
const result = spawnSync("python3", [script, ...args], { stdio: "inherit", cwd: root, env: { ...process.env, MAGGIE_VERSION: PACKAGE_VERSION } });
|
|
312
314
|
if (result.error) throw result.error;
|
|
@@ -327,7 +329,7 @@ function seo(args) {
|
|
|
327
329
|
|
|
328
330
|
function workflowCli(name, args) {
|
|
329
331
|
const root = projectRoot(args);
|
|
330
|
-
const script =
|
|
332
|
+
const script = toolScript(name, root);
|
|
331
333
|
if (!existsSync(script)) throw new Error(`${name} is missing: ${script}`);
|
|
332
334
|
const env = name === "maggie_feedback.py" ? { ...process.env, MAGGIE_VERSION: PACKAGE_VERSION } : process.env;
|
|
333
335
|
const result = spawnSync("python3", [script, ...args], { stdio: "inherit", cwd: root, env });
|
|
@@ -335,6 +337,15 @@ function workflowCli(name, args) {
|
|
|
335
337
|
process.exitCode = result.status ?? 1;
|
|
336
338
|
}
|
|
337
339
|
|
|
340
|
+
function toolScript(name, root) {
|
|
341
|
+
const bundled = join(TOOLS_ROOT, "clis", name);
|
|
342
|
+
return existsSync(bundled) ? bundled : join(root, "tools", "clis", name);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function version() {
|
|
346
|
+
console.log(PACKAGE_VERSION);
|
|
347
|
+
}
|
|
348
|
+
|
|
338
349
|
function doctor(args) {
|
|
339
350
|
const root = projectRoot(args);
|
|
340
351
|
const checks = [
|
|
@@ -404,7 +415,8 @@ function cleanup(args) {
|
|
|
404
415
|
|
|
405
416
|
const [command = "help", ...args] = process.argv.slice(2);
|
|
406
417
|
try {
|
|
407
|
-
if (["
|
|
418
|
+
if (["--version", "-v", "version"].includes(command)) version();
|
|
419
|
+
else if (["help", "--help", "-h"].includes(command)) usage();
|
|
408
420
|
else if (command === "list") list();
|
|
409
421
|
else if (command === "marketplace") marketplace(args);
|
|
410
422
|
else if (command === "clone") workflowCli("maggie_clone.py", args);
|
|
@@ -3,17 +3,35 @@
|
|
|
3
3
|
"$id": "https://maggie.noblox.app/contracts/deployment-runtime-parity-v1.json",
|
|
4
4
|
"title": "Maggie deployment worker and browser runtime parity evidence",
|
|
5
5
|
"type": "object",
|
|
6
|
-
"required": ["schemaVersion", "environment", "passed", "worker", "browser"],
|
|
6
|
+
"required": ["schemaVersion", "environment", "passed", "release", "worker", "browser"],
|
|
7
7
|
"properties": {
|
|
8
8
|
"schemaVersion": {"const": "maggie-deployment-runtime-parity.v1"},
|
|
9
9
|
"environment": {"enum": ["development", "staging", "production"]},
|
|
10
10
|
"passed": {"const": true},
|
|
11
|
+
"release": {
|
|
12
|
+
"type": "object",
|
|
13
|
+
"required": ["activeRevision", "expectedRevision", "rollbackRevision", "environment", "runtimeConfigHash", "expectedConfigHash", "migrationVersion", "expectedMigrationVersion", "workerRevision", "assetVersions"],
|
|
14
|
+
"properties": {
|
|
15
|
+
"activeRevision": {"type": "string", "minLength": 1},
|
|
16
|
+
"expectedRevision": {"type": "string", "minLength": 1},
|
|
17
|
+
"rollbackRevision": {"type": "string", "minLength": 1},
|
|
18
|
+
"environment": {"enum": ["development", "staging", "production"]},
|
|
19
|
+
"runtimeConfigHash": {"type": "string", "pattern": "^[0-9a-f]{64}$"},
|
|
20
|
+
"expectedConfigHash": {"type": "string", "pattern": "^[0-9a-f]{64}$"},
|
|
21
|
+
"migrationVersion": {"type": "string", "minLength": 1},
|
|
22
|
+
"expectedMigrationVersion": {"type": "string", "minLength": 1},
|
|
23
|
+
"workerRevision": {"type": "string", "minLength": 1},
|
|
24
|
+
"assetVersions": {"type": "array", "minItems": 1, "items": {"type": "object", "required": ["name", "sha256"], "properties": {"name": {"type": "string"}, "sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}}, "additionalProperties": false}}
|
|
25
|
+
},
|
|
26
|
+
"additionalProperties": true
|
|
27
|
+
},
|
|
11
28
|
"worker": {
|
|
12
29
|
"type": "object",
|
|
13
30
|
"required": ["config", "migration", "routeApis", "residentProcesses", "sourceJobs"],
|
|
14
31
|
"properties": {
|
|
15
32
|
"config": {"$ref": "#/$defs/config"},
|
|
16
33
|
"migration": {"type": "object", "required": ["privilegeCheck"], "properties": {"privilegeCheck": {"$ref": "#/$defs/passed"}}},
|
|
34
|
+
"revision": {"type": "string", "minLength": 1},
|
|
17
35
|
"routeApis": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/route"}},
|
|
18
36
|
"residentProcesses": {"$ref": "#/$defs/processes"},
|
|
19
37
|
"sourceJobs": {"type": "array", "items": {"$ref": "#/$defs/job"}}
|
|
@@ -30,7 +48,7 @@
|
|
|
30
48
|
"$defs": {
|
|
31
49
|
"passed": {"type": "object", "required": ["passed"], "properties": {"passed": {"const": true}}, "additionalProperties": true},
|
|
32
50
|
"config": {"type": "object", "required": ["required", "missing", "passed"], "properties": {"required": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}}, "missing": {"type": "array", "maxItems": 0}, "passed": {"const": true}}, "additionalProperties": true},
|
|
33
|
-
"route": {"type": "object", "required": ["method", "path", "status", "passed"], "properties": {"method": {"type": "string"}, "path": {"type": "string", "pattern": "^/"}, "status": {"type": "integer", "minimum": 200, "maximum": 399}, "passed": {"const": true}}, "additionalProperties": true},
|
|
51
|
+
"route": {"type": "object", "required": ["method", "path", "status", "passed", "releaseRevision"], "properties": {"method": {"type": "string"}, "path": {"type": "string", "pattern": "^/"}, "status": {"type": "integer", "minimum": 200, "maximum": 399}, "passed": {"const": true}, "releaseRevision": {"type": "string", "minLength": 1}}, "additionalProperties": true},
|
|
34
52
|
"processes": {"type": "object", "required": ["checked", "unexpected", "stale", "passed"], "properties": {"checked": {"const": true}, "unexpected": {"type": "array", "maxItems": 0}, "stale": {"type": "array", "maxItems": 0}, "passed": {"const": true}}, "additionalProperties": true},
|
|
35
53
|
"job": {"type": "object", "required": ["jobId", "passed"], "properties": {"jobId": {"type": "string", "minLength": 1}, "passed": {"const": true}}, "additionalProperties": true}
|
|
36
54
|
},
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://maggie.noblox.app/contracts/maggie-seo/model-policy-v1.schema.json",
|
|
4
|
+
"title": "Maggie Gemini model policy v1",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["policyVersion", "model", "fallbackModel", "promptVersion", "schemaVersion", "locale", "sourceRevision"],
|
|
7
|
+
"properties": {
|
|
8
|
+
"policyVersion": {"const": "maggie-gemini-model-policy.v1"},
|
|
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
|
+
},
|
|
16
|
+
"additionalProperties": false
|
|
17
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://maggie.noblox.app/contracts/resolver-v1.schema.json",
|
|
4
|
+
"title": "Maggie booking URL resolver outcomes v1",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["schemaVersion", "status", "passed", "errors", "counts", "observations", "privacy"],
|
|
7
|
+
"properties": {
|
|
8
|
+
"schemaVersion": {"const": "maggie-booking-resolver.v1"},
|
|
9
|
+
"status": {"enum": ["passed", "failed"]},
|
|
10
|
+
"passed": {"type": "boolean"},
|
|
11
|
+
"errors": {"type": "array", "items": {"type": "string"}},
|
|
12
|
+
"counts": {"type": "object", "additionalProperties": {"type": "integer", "minimum": 0}},
|
|
13
|
+
"observations": {"type": "array", "maxItems": 200, "items": {"type": "object"}},
|
|
14
|
+
"privacy": {"type": "object", "required": ["rawResponsesIncluded", "credentialsIncluded"]}
|
|
15
|
+
},
|
|
16
|
+
"additionalProperties": false
|
|
17
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://maggie.noblox.app/contracts/worker-health-v1.schema.json",
|
|
4
|
+
"title": "Maggie service booking worker health v1",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["schemaVersion", "environment", "workerId", "status", "passed", "errors", "checks"],
|
|
7
|
+
"properties": {
|
|
8
|
+
"schemaVersion": {"const": "maggie-booking-worker-health.v1"},
|
|
9
|
+
"environment": {"enum": ["development", "staging", "production"]},
|
|
10
|
+
"workerId": {"type": "string", "minLength": 1},
|
|
11
|
+
"status": {"enum": ["passed", "failed"]},
|
|
12
|
+
"passed": {"type": "boolean"},
|
|
13
|
+
"errors": {"type": "array", "items": {"type": "string"}},
|
|
14
|
+
"checks": {"type": "object", "additionalProperties": {"type": "boolean"}}
|
|
15
|
+
},
|
|
16
|
+
"additionalProperties": false
|
|
17
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://maggiedash.noblox.app/contracts/migration-preflight-v1.schema.json",
|
|
4
|
+
"title": "MaggieDash Migration Preflight v1",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["schemaVersion", "environment", "status", "passed", "errors", "checks", "mutation"],
|
|
7
|
+
"properties": {
|
|
8
|
+
"schemaVersion": {"const": "maggie-migration-preflight.v1"},
|
|
9
|
+
"environment": {"enum": ["development", "staging", "production"]},
|
|
10
|
+
"status": {"enum": ["passed", "failed"]}, "passed": {"type": "boolean"},
|
|
11
|
+
"errors": {"type": "array", "items": {"type": "string"}},
|
|
12
|
+
"checks": {"type": "object", "additionalProperties": {"type": "boolean"}},
|
|
13
|
+
"mutation": {"const": "not executed"}
|
|
14
|
+
},
|
|
15
|
+
"additionalProperties": false
|
|
16
|
+
}
|
|
@@ -5,6 +5,18 @@ description: Manage the MaggieDash project foundation, local content store, and
|
|
|
5
5
|
|
|
6
6
|
# MaggieDash
|
|
7
7
|
|
|
8
|
+
Run the read-only migration preflight before any schema write:
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
maggie migration preflight \
|
|
12
|
+
--evidence .maggie/migration/preflight-input.json \
|
|
13
|
+
--output .maggie/migration/preflight.json
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
It checks database privileges, required columns, unique constraints used by
|
|
17
|
+
`ON CONFLICT`, idempotency, and version advancement. Evidence is secret-free
|
|
18
|
+
and the command never executes a migration; a failed report blocks the write.
|
|
19
|
+
|
|
8
20
|
Use the provider-neutral MaggieDash contracts and CLI for project setup,
|
|
9
21
|
installation of the first-party admin workspace, and content operations.
|
|
10
22
|
|
|
@@ -52,10 +52,14 @@ maggie deployment parity --project . \
|
|
|
52
52
|
The `maggie-deployment-runtime-parity.v1` contract checks required worker
|
|
53
53
|
configuration and missing bindings, migration privileges, route API status,
|
|
54
54
|
stale/unexpected resident import processes, non-empty source-job IDs, and
|
|
55
|
-
browser/driver compatibility. It
|
|
56
|
-
the
|
|
57
|
-
|
|
58
|
-
|
|
55
|
+
browser/driver compatibility. It also requires the active release revision to
|
|
56
|
+
match the expected revision, an equal runtime configuration hash, the expected
|
|
57
|
+
migration and worker revisions, non-empty asset fingerprints, route release
|
|
58
|
+
revisions, and a valid rollback target. It is provider-neutral: the host
|
|
59
|
+
adapter runs the environment checks and supplies only safe facts; Maggie
|
|
60
|
+
validates the evidence and never receives credentials, cookies, or response
|
|
61
|
+
bodies. A failed parity report blocks the readiness command and therefore the
|
|
62
|
+
canary.
|
|
59
63
|
|
|
60
64
|
## Release readiness evidence
|
|
61
65
|
|
|
@@ -55,9 +55,19 @@ The batch file contains 1–50 items and may provide shared `skill`, `runId`,
|
|
|
55
55
|
`phase`, `priority`, and `affectedCli` values. An item can override those
|
|
56
56
|
fields and supplies the normal `type`, `summary`, `expected`, `actual`,
|
|
57
57
|
`errorFingerprint`, reproduction, resolution, validation, and screenshot
|
|
58
|
-
metadata fields.
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
metadata fields. It may also provide bounded `routeIds` and
|
|
59
|
+
`validationEvidence` identifiers. Each generated draft stores only the safe
|
|
60
|
+
batch ID, zero-based index, and bounded size. The batch command still creates
|
|
61
|
+
local drafts only.
|
|
62
|
+
|
|
63
|
+
Aggregate a batch before manual review:
|
|
64
|
+
|
|
65
|
+
maggie feedback batch-review --project . --batch-id run-review-001
|
|
66
|
+
|
|
67
|
+
This writes `.maggie/feedback/batches/<batch-id>-review.json` under the
|
|
68
|
+
`maggie-feedback-batch-review.v1` contract. It reports missing indexes and
|
|
69
|
+
duplicate fingerprints/summaries, while retaining only redacted observations;
|
|
70
|
+
review the aggregate and each draft before submitting.
|
|
61
71
|
|
|
62
72
|
Example:
|
|
63
73
|
|
|
@@ -7,6 +7,22 @@ metadata:
|
|
|
7
7
|
|
|
8
8
|
# Maggie Marketplace
|
|
9
9
|
|
|
10
|
+
## Enrichment evidence
|
|
11
|
+
|
|
12
|
+
Candidate discovery must preserve enough structured evidence to debug a
|
|
13
|
+
missing Google candidate, crawl, audit, snapshot, or SEO backfill without
|
|
14
|
+
storing provider HTML or credentials:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
maggie marketplace enrichment-report --project . \
|
|
18
|
+
--input .maggie/marketplace/enrichment-input.json
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The output follows `maggie-marketplace-enrichment.v1`, records safe status and
|
|
22
|
+
error codes for each stage, and makes retry eligibility explicit. Null/error
|
|
23
|
+
reasons remain visible for a maintainer; raw response bodies, local paths, and
|
|
24
|
+
secrets are excluded.
|
|
25
|
+
|
|
10
26
|
## Automatic memory hook
|
|
11
27
|
|
|
12
28
|
Follow [Maggie Memory Hook](../../references/memory-hook.md) at invocation and completion.
|
|
@@ -7,6 +7,17 @@ metadata:
|
|
|
7
7
|
|
|
8
8
|
# Maggie SEO and GEO
|
|
9
9
|
|
|
10
|
+
## Versioned Gemini policy
|
|
11
|
+
|
|
12
|
+
AI-assisted SEO, localization, and content generation must resolve models
|
|
13
|
+
through one explicit policy: `maggie-gemini-model-policy.v1`. Pin the primary
|
|
14
|
+
model, declare a separate fallback, and record the policy version, exact
|
|
15
|
+
prompt/schema versions, locale, and source revision with generated evidence.
|
|
16
|
+
Only model availability failures (retired model, quota, timeout, or provider
|
|
17
|
+
5xx) may roll forward to the declared fallback; authentication failures and
|
|
18
|
+
invalid output remain errors. Do not scatter retired model defaults across
|
|
19
|
+
routes.
|
|
20
|
+
|
|
10
21
|
## Static audit and sitemap evidence
|
|
11
22
|
|
|
12
23
|
Run `maggie site-audit https://example.com --crawl --json` for sitemap-listed
|
|
@@ -7,6 +7,25 @@ metadata:
|
|
|
7
7
|
|
|
8
8
|
# Maggie Service Booking
|
|
9
9
|
|
|
10
|
+
## Bounded worker and resolver evidence
|
|
11
|
+
|
|
12
|
+
Before enabling provider crawling or URL resolution, validate sanitized host
|
|
13
|
+
evidence. These checks keep queue depth, leases, concurrency, timeouts,
|
|
14
|
+
retries, cancellation cleanup, and worker health explicit:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
maggie service worker-health --project . \
|
|
18
|
+
--evidence .maggie/booking/worker-health-input.json
|
|
19
|
+
maggie service resolver-audit --project . \
|
|
20
|
+
--evidence .maggie/booking/resolver-input.json
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The worker report uses `maggie-booking-worker-health.v1`. Resolver outcomes are
|
|
24
|
+
limited to `resolved`, `resolved-no-menu`, `not-found`, `blocked`, `timeout`,
|
|
25
|
+
and `unavailable`; resolved outcomes require a public canonical HTTPS URL and
|
|
26
|
+
safe evidence IDs. Raw provider responses, credentials, logs, and request
|
|
27
|
+
bodies are never copied into the reports.
|
|
28
|
+
|
|
10
29
|
Route matching evidence must come from the route source's actual import
|
|
11
30
|
statements. The shared resolver in `tools/runtime/route_imports.py` resolves
|
|
12
31
|
relative imports and records a component fingerprint; it never selects a
|
|
@@ -14,6 +14,7 @@ from typing import Any
|
|
|
14
14
|
SCHEMA = "maggie-deployment-runtime-parity.v1"
|
|
15
15
|
ENVIRONMENTS = {"development", "staging", "production"}
|
|
16
16
|
SAFE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$")
|
|
17
|
+
SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
|
17
18
|
|
|
18
19
|
|
|
19
20
|
def load(path: Path) -> dict[str, Any]:
|
|
@@ -42,6 +43,38 @@ def validate(payload: dict[str, Any]) -> list[str]:
|
|
|
42
43
|
if payload.get("passed") is not True:
|
|
43
44
|
errors.append("parity evidence is not marked passed")
|
|
44
45
|
|
|
46
|
+
release = payload.get("release")
|
|
47
|
+
if not isinstance(release, dict):
|
|
48
|
+
errors.append("release evidence must be an object")
|
|
49
|
+
release = {}
|
|
50
|
+
for field in ("activeRevision", "expectedRevision", "rollbackRevision"):
|
|
51
|
+
if not isinstance(release.get(field), str) or not SAFE_ID.fullmatch(release[field]):
|
|
52
|
+
errors.append(f"release.{field} must be a safe identifier")
|
|
53
|
+
if release.get("activeRevision") != release.get("expectedRevision"):
|
|
54
|
+
errors.append("active release revision does not match expected revision")
|
|
55
|
+
if release.get("rollbackRevision") == release.get("activeRevision"):
|
|
56
|
+
errors.append("rollback revision must differ from the active revision")
|
|
57
|
+
if release.get("environment") != payload.get("environment"):
|
|
58
|
+
errors.append("release environment does not match parity environment")
|
|
59
|
+
for field in ("runtimeConfigHash", "expectedConfigHash"):
|
|
60
|
+
if not isinstance(release.get(field), str) or not SHA256.fullmatch(release[field]):
|
|
61
|
+
errors.append(f"release.{field} must be a SHA-256 hex digest")
|
|
62
|
+
if release.get("runtimeConfigHash") != release.get("expectedConfigHash"):
|
|
63
|
+
errors.append("active runtime configuration does not match expected configuration")
|
|
64
|
+
for field in ("migrationVersion", "expectedMigrationVersion", "workerRevision"):
|
|
65
|
+
if not isinstance(release.get(field), str) or not SAFE_ID.fullmatch(release[field]):
|
|
66
|
+
errors.append(f"release.{field} must be a safe identifier")
|
|
67
|
+
if release.get("migrationVersion") != release.get("expectedMigrationVersion"):
|
|
68
|
+
errors.append("active migration version does not match expected migration version")
|
|
69
|
+
assets = release.get("assetVersions")
|
|
70
|
+
if not isinstance(assets, list) or not assets:
|
|
71
|
+
errors.append("release.assetVersions must contain at least one asset hash")
|
|
72
|
+
for index, asset in enumerate(assets if isinstance(assets, list) else []):
|
|
73
|
+
if not isinstance(asset, dict) or not SAFE_ID.fullmatch(str(asset.get("name") or "")):
|
|
74
|
+
errors.append(f"release.assetVersions[{index}].name must be a safe identifier")
|
|
75
|
+
if not isinstance(asset, dict) or not SHA256.fullmatch(str(asset.get("sha256") or "")):
|
|
76
|
+
errors.append(f"release.assetVersions[{index}].sha256 must be a SHA-256 hex digest")
|
|
77
|
+
|
|
45
78
|
worker = payload.get("worker")
|
|
46
79
|
if not isinstance(worker, dict):
|
|
47
80
|
errors.append("worker evidence must be an object")
|
|
@@ -58,6 +91,8 @@ def validate(payload: dict[str, Any]) -> list[str]:
|
|
|
58
91
|
errors.append("worker.config has missing requirements")
|
|
59
92
|
if config.get("passed") is not True:
|
|
60
93
|
errors.append("worker.config did not pass")
|
|
94
|
+
if worker.get("revision") != release.get("activeRevision"):
|
|
95
|
+
errors.append("worker revision does not match the active release")
|
|
61
96
|
|
|
62
97
|
migration = worker.get("migration")
|
|
63
98
|
privilege = migration.get("privilegeCheck") if isinstance(migration, dict) else None
|
|
@@ -84,6 +119,8 @@ def validate(payload: dict[str, Any]) -> list[str]:
|
|
|
84
119
|
errors.append(f"{prefix}.status must be a successful HTTP status")
|
|
85
120
|
if route.get("passed") is not True:
|
|
86
121
|
errors.append(f"{prefix} did not pass")
|
|
122
|
+
if route.get("releaseRevision") != release.get("activeRevision"):
|
|
123
|
+
errors.append(f"{prefix}.releaseRevision does not match the active release")
|
|
87
124
|
|
|
88
125
|
processes = worker.get("residentProcesses")
|
|
89
126
|
if not isinstance(processes, dict):
|
|
@@ -135,6 +172,11 @@ def parity(evidence: Path, output: Path) -> int:
|
|
|
135
172
|
"status": "passed" if not errors else "failed",
|
|
136
173
|
"passed": not errors,
|
|
137
174
|
"checks": {
|
|
175
|
+
"activeRelease": "validated",
|
|
176
|
+
"runtimeConfiguration": "validated",
|
|
177
|
+
"migrationVersion": "validated",
|
|
178
|
+
"assetVersions": "validated",
|
|
179
|
+
"rollbackTarget": "validated",
|
|
138
180
|
"workerConfig": "validated",
|
|
139
181
|
"migrationPrivileges": "validated",
|
|
140
182
|
"routeApis": "validated",
|
|
@@ -28,6 +28,10 @@ LOCAL_PATH_RE = re.compile(
|
|
|
28
28
|
)
|
|
29
29
|
VERSION_RE = re.compile(r"\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?")
|
|
30
30
|
SAFE_BATCH_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$")
|
|
31
|
+
SAFE_ROUTE_RE = re.compile(r"^/[A-Za-z0-9._~!$&'()*+,;=:@%/?#-]{1,240}$")
|
|
32
|
+
SAFE_EVIDENCE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/=-]{0,159}$")
|
|
33
|
+
MAX_ROUTE_IDS = 20
|
|
34
|
+
MAX_VALIDATION_EVIDENCE = 20
|
|
31
35
|
ACKNOWLEDGEMENT_KEYS = ("status", "feedbackId", "requestId")
|
|
32
36
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
33
37
|
|
|
@@ -107,6 +111,29 @@ def load_json(path: Path) -> dict:
|
|
|
107
111
|
return value
|
|
108
112
|
|
|
109
113
|
|
|
114
|
+
def safe_route_ids(values: object) -> list[str]:
|
|
115
|
+
"""Keep bounded route identifiers, never request bodies or local paths."""
|
|
116
|
+
if not isinstance(values, list):
|
|
117
|
+
return []
|
|
118
|
+
result = []
|
|
119
|
+
for value in values[:MAX_ROUTE_IDS]:
|
|
120
|
+
item = safe_text(value)
|
|
121
|
+
if SAFE_ROUTE_RE.fullmatch(item):
|
|
122
|
+
result.append(item)
|
|
123
|
+
return sorted(set(result))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def safe_validation_evidence(values: object) -> list[str]:
|
|
127
|
+
if not isinstance(values, list):
|
|
128
|
+
return []
|
|
129
|
+
result = []
|
|
130
|
+
for value in values[:MAX_VALIDATION_EVIDENCE]:
|
|
131
|
+
item = safe_text(value)
|
|
132
|
+
if SAFE_EVIDENCE_RE.fullmatch(item):
|
|
133
|
+
result.append(item)
|
|
134
|
+
return sorted(set(result))
|
|
135
|
+
|
|
136
|
+
|
|
110
137
|
def collect(args: argparse.Namespace) -> int:
|
|
111
138
|
project = Path(args.project).resolve()
|
|
112
139
|
report = load_json(Path(args.run_report)) if args.run_report else {}
|
|
@@ -132,6 +159,8 @@ def collect(args: argparse.Namespace) -> int:
|
|
|
132
159
|
if priority not in {"low", "normal", "high", "critical"}:
|
|
133
160
|
raise ValueError("priority must be low, normal, high, or critical")
|
|
134
161
|
affected_cli = safe_text(getattr(args, "affected_cli", ""))
|
|
162
|
+
route_ids = safe_route_ids(getattr(args, "route_ids", []))
|
|
163
|
+
validation_evidence = safe_validation_evidence(getattr(args, "validation_evidence", []))
|
|
135
164
|
context = {"projectFingerprint": project_fingerprint(project)}
|
|
136
165
|
if args.allow_project_context and args.context_note:
|
|
137
166
|
context["note"] = safe_text(args.context_note)
|
|
@@ -156,6 +185,8 @@ def collect(args: argparse.Namespace) -> int:
|
|
|
156
185
|
"validation": validation,
|
|
157
186
|
"priority": priority,
|
|
158
187
|
"affectedCli": affected_cli,
|
|
188
|
+
"routeIds": route_ids,
|
|
189
|
+
"validationEvidence": validation_evidence,
|
|
159
190
|
"attachments": [attachment(value) for value in args.screenshot],
|
|
160
191
|
"environment": {"os": platform.system().lower(), "python": platform.python_version()},
|
|
161
192
|
"privacy": {"secretsRedacted": True, "projectContextAllowed": bool(args.allow_project_context)},
|
|
@@ -264,6 +295,8 @@ def collect_batch(args: argparse.Namespace) -> int:
|
|
|
264
295
|
"phase": safe_text(manifest.get("phase")),
|
|
265
296
|
"priority": safe_text(manifest.get("priority") or "normal"),
|
|
266
297
|
"affected_cli": safe_text(manifest.get("affectedCli")),
|
|
298
|
+
"route_ids": safe_route_ids(manifest.get("routeIds", [])),
|
|
299
|
+
"validation_evidence": safe_validation_evidence(manifest.get("validationEvidence", [])),
|
|
267
300
|
}
|
|
268
301
|
paths = []
|
|
269
302
|
for index, item in enumerate(items):
|
|
@@ -292,14 +325,82 @@ def collect_batch(args: argparse.Namespace) -> int:
|
|
|
292
325
|
"batch_size": len(items),
|
|
293
326
|
"priority": item.get("priority") or shared["priority"],
|
|
294
327
|
"affected_cli": item.get("affectedCli") or shared["affected_cli"],
|
|
328
|
+
"route_ids": item.get("routeIds", shared["route_ids"]),
|
|
329
|
+
"validation_evidence": item.get("validationEvidence", shared["validation_evidence"]),
|
|
295
330
|
}
|
|
296
331
|
if not isinstance(values["reproduce"], list): values["reproduce"] = []
|
|
297
332
|
if not isinstance(values["screenshot"], list): values["screenshot"] = []
|
|
333
|
+
values["route_ids"] = safe_route_ids(values["route_ids"])
|
|
334
|
+
values["validation_evidence"] = safe_validation_evidence(values["validation_evidence"])
|
|
298
335
|
paths.append(collect(argparse.Namespace(**values)))
|
|
299
336
|
print(json.dumps({"batchId": batch_id, "count": len(paths), "status": "drafts-created"}, ensure_ascii=False))
|
|
300
337
|
return 0
|
|
301
338
|
|
|
302
339
|
|
|
340
|
+
def batch_review(args: argparse.Namespace) -> int:
|
|
341
|
+
"""Aggregate one bounded batch without exposing raw payloads or paths."""
|
|
342
|
+
project = Path(args.project).resolve()
|
|
343
|
+
batch_id = safe_text(args.batch_id)
|
|
344
|
+
if not SAFE_BATCH_ID_RE.fullmatch(batch_id):
|
|
345
|
+
raise ValueError("batch ID is invalid")
|
|
346
|
+
records = []
|
|
347
|
+
expected_sizes = []
|
|
348
|
+
for path in sorted((project / ".maggie" / "feedback").glob("*.json")):
|
|
349
|
+
try:
|
|
350
|
+
data = read_feedback(path)
|
|
351
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
352
|
+
continue
|
|
353
|
+
batch = data.get("batch") if isinstance(data.get("batch"), dict) else {}
|
|
354
|
+
if batch.get("batchId") != batch_id:
|
|
355
|
+
continue
|
|
356
|
+
if isinstance(batch.get("size"), int):
|
|
357
|
+
expected_sizes.append(batch["size"])
|
|
358
|
+
records.append({
|
|
359
|
+
"feedbackId": safe_text(data.get("feedbackId")),
|
|
360
|
+
"index": batch.get("index"),
|
|
361
|
+
"type": safe_text(data.get("type")),
|
|
362
|
+
"skill": safe_text(data.get("skill")),
|
|
363
|
+
"phase": safe_text(data.get("phase")),
|
|
364
|
+
"summary": safe_text(data.get("summary")),
|
|
365
|
+
"expected": safe_text(data.get("expected")),
|
|
366
|
+
"actual": safe_text(data.get("actual")),
|
|
367
|
+
"errorFingerprint": safe_text(data.get("errorFingerprint")),
|
|
368
|
+
"fixed": data.get("fixed") is True,
|
|
369
|
+
"resolution": safe_text(data.get("resolution")),
|
|
370
|
+
"validation": safe_text(data.get("validation")),
|
|
371
|
+
"routeIds": safe_route_ids(data.get("routeIds", [])),
|
|
372
|
+
"validationEvidence": safe_validation_evidence(data.get("validationEvidence", [])),
|
|
373
|
+
})
|
|
374
|
+
records.sort(key=lambda item: (item["index"] is None, item["index"] if isinstance(item["index"], int) else 0, item["feedbackId"]))
|
|
375
|
+
expected_count = max(expected_sizes, default=len(records))
|
|
376
|
+
seen_indexes = {item["index"] for item in records if isinstance(item["index"], int)}
|
|
377
|
+
duplicate_groups = {}
|
|
378
|
+
for item in records:
|
|
379
|
+
key = item["errorFingerprint"] or re.sub(r"\s+", " ", item["summary"].lower()).strip()
|
|
380
|
+
if key:
|
|
381
|
+
duplicate_groups.setdefault(key, []).append(item["feedbackId"])
|
|
382
|
+
duplicates = [{"key": safe_text(key), "feedbackIds": ids} for key, ids in duplicate_groups.items() if len(ids) > 1]
|
|
383
|
+
output = Path(args.output) if args.output else project / ".maggie" / "feedback" / "batches" / f"{batch_id}-review.json"
|
|
384
|
+
if not output.is_absolute(): output = project / output
|
|
385
|
+
report = {
|
|
386
|
+
"schemaVersion": "maggie-feedback-batch-review.v1",
|
|
387
|
+
"batchId": batch_id,
|
|
388
|
+
"status": "passed" if records and not duplicates and len(records) == expected_count else "needs-review",
|
|
389
|
+
"expectedCount": expected_count,
|
|
390
|
+
"draftCount": len(records),
|
|
391
|
+
"missingIndexes": sorted(set(range(expected_count)) - seen_indexes),
|
|
392
|
+
"skills": sorted({item["skill"] for item in records if item["skill"]}),
|
|
393
|
+
"phases": sorted({item["phase"] for item in records if item["phase"]}),
|
|
394
|
+
"observations": records,
|
|
395
|
+
"duplicates": duplicates,
|
|
396
|
+
"privacy": {"rawPayloadsIncluded": False, "localPathsIncluded": False, "secretsRedacted": True},
|
|
397
|
+
}
|
|
398
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
399
|
+
output.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
400
|
+
print(json.dumps({"status": report["status"], "batchId": batch_id, "draftCount": len(records), "expectedCount": expected_count, "duplicates": len(duplicates), "report": str(output.resolve())}, ensure_ascii=False))
|
|
401
|
+
return 0 if report["status"] == "passed" else 1
|
|
402
|
+
|
|
403
|
+
|
|
303
404
|
def main() -> int:
|
|
304
405
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
305
406
|
parser.add_argument("--project", default=".")
|
|
@@ -329,9 +430,15 @@ def main() -> int:
|
|
|
329
430
|
collect_parser.add_argument("--batch-size", type=int)
|
|
330
431
|
collect_parser.add_argument("--priority", choices=("low", "normal", "high", "critical"), default="normal")
|
|
331
432
|
collect_parser.add_argument("--affected-cli", default="")
|
|
433
|
+
collect_parser.add_argument("--route-id", dest="route_ids", action="append", default=[])
|
|
434
|
+
collect_parser.add_argument("--validation-evidence", dest="validation_evidence", action="append", default=[])
|
|
332
435
|
batch_parser = sub.add_parser("batch")
|
|
333
436
|
batch_parser.add_argument("--project", default=argparse.SUPPRESS)
|
|
334
437
|
batch_parser.add_argument("--batch-file", required=True)
|
|
438
|
+
review_parser = sub.add_parser("batch-review")
|
|
439
|
+
review_parser.add_argument("--project", default=argparse.SUPPRESS)
|
|
440
|
+
review_parser.add_argument("--batch-id", required=True)
|
|
441
|
+
review_parser.add_argument("--output")
|
|
335
442
|
preview_parser = sub.add_parser("preview")
|
|
336
443
|
preview_parser.add_argument("feedback")
|
|
337
444
|
preview_parser.add_argument("--format", choices=("json", "markdown"), default="json")
|
|
@@ -344,6 +451,7 @@ def main() -> int:
|
|
|
344
451
|
try:
|
|
345
452
|
if args.command == "collect": return collect(args)
|
|
346
453
|
if args.command == "batch": return collect_batch(args)
|
|
454
|
+
if args.command == "batch-review": return batch_review(args)
|
|
347
455
|
if args.command == "preview": return preview(args)
|
|
348
456
|
if args.command == "submit": return submit(args)
|
|
349
457
|
return list_feedback(args)
|
|
@@ -216,7 +216,15 @@ def plan_job(args: argparse.Namespace) -> int:
|
|
|
216
216
|
"translationGroupId": identity.get("translationGroupId", f"tg-{content_id}"),
|
|
217
217
|
"sourceRevision": args.source_revision or (source_artifact or {}).get("sourceRevision") or content.get("sourceRevision", "unknown"),
|
|
218
218
|
"status": "draft",
|
|
219
|
-
"translation": {
|
|
219
|
+
"translation": {
|
|
220
|
+
"translationStatus": "draft", "isIndexable": False,
|
|
221
|
+
"provenance": {
|
|
222
|
+
"operation": args.operation, "sourceRevision": args.source_revision or (source_artifact or {}).get("sourceRevision") or content.get("sourceRevision", "unknown"),
|
|
223
|
+
"sourceLanguage": args.source_lang, "targetLanguage": args.target_lang,
|
|
224
|
+
"targetLocale": args.locale, "market": args.market,
|
|
225
|
+
"evidenceIds": [f"content:{content_id}"], "createdAt": now(),
|
|
226
|
+
},
|
|
227
|
+
},
|
|
220
228
|
"createdAt": now(),
|
|
221
229
|
}
|
|
222
230
|
if source_artifact:
|
|
@@ -246,6 +254,15 @@ def validate_job(path: Path, source_path: Path | None = None, render_path: Path
|
|
|
246
254
|
errors.append("targetLocale must be a supported tag matching targetLanguage")
|
|
247
255
|
translation = job.get("translation", {})
|
|
248
256
|
generation = job.get("generationContract", {})
|
|
257
|
+
provenance = translation.get("provenance", {}) if isinstance(translation, dict) else {}
|
|
258
|
+
if not isinstance(provenance, dict):
|
|
259
|
+
errors.append("translation.provenance must be an object")
|
|
260
|
+
provenance = {}
|
|
261
|
+
for field in ("sourceLanguage", "targetLanguage", "targetLocale", "market"):
|
|
262
|
+
if provenance.get(field) != job.get({"sourceLanguage": "sourceLanguage", "targetLanguage": "targetLanguage", "targetLocale": "targetLocale", "market": "market"}[field]):
|
|
263
|
+
errors.append(f"translation.provenance.{field} does not match the job")
|
|
264
|
+
if provenance.get("sourceRevision") != job.get("sourceRevision"):
|
|
265
|
+
errors.append("translation.provenance.sourceRevision does not match the job")
|
|
249
266
|
if generation.get("mode") and generation.get("mode") != job.get("operation"):
|
|
250
267
|
errors.append("generationContract mode must match operation")
|
|
251
268
|
if job.get("operation") == "rewrite" and not generation.get("allowStructuralRewrite"):
|
|
@@ -2061,6 +2061,47 @@ def apply_template(args: argparse.Namespace) -> int:
|
|
|
2061
2061
|
return 0
|
|
2062
2062
|
|
|
2063
2063
|
|
|
2064
|
+
def enrichment_report(args: argparse.Namespace) -> int:
|
|
2065
|
+
"""Normalize privacy-safe enrichment evidence for a marketplace candidate."""
|
|
2066
|
+
try:
|
|
2067
|
+
payload = read_json(args.input.resolve())
|
|
2068
|
+
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
2069
|
+
print(json.dumps({"status": "failed", "errors": [f"cannot read enrichment evidence: {error}"]})); return 1
|
|
2070
|
+
errors = []
|
|
2071
|
+
if payload.get("schemaVersion") != "maggie-marketplace-enrichment-input.v1": errors.append("schemaVersion is unsupported")
|
|
2072
|
+
candidate = payload.get("candidate") if isinstance(payload.get("candidate"), dict) else {}
|
|
2073
|
+
candidate_id = str(candidate.get("candidateId") or "")
|
|
2074
|
+
if not ID_PATTERN.fullmatch(candidate_id): errors.append("candidate.candidateId must be marketplace-safe")
|
|
2075
|
+
crawl = payload.get("crawl") if isinstance(payload.get("crawl"), dict) else {}
|
|
2076
|
+
audit = payload.get("audit") if isinstance(payload.get("audit"), dict) else {}
|
|
2077
|
+
snapshot = payload.get("snapshot") if isinstance(payload.get("snapshot"), dict) else {}
|
|
2078
|
+
for field, item in (("crawl", crawl), ("audit", audit), ("snapshot", snapshot)):
|
|
2079
|
+
if not item: errors.append(f"{field} evidence is required")
|
|
2080
|
+
if item.get("status") not in {"passed", "failed", "not-found", "blocked", "unavailable"}: errors.append(f"{field}.status is unsupported")
|
|
2081
|
+
if item.get("error") and not re.fullmatch(r"[a-z0-9][a-z0-9._:-]{0,119}", str(item["error"])): errors.append(f"{field}.error must be a safe error code")
|
|
2082
|
+
fields = payload.get("rawAuditFields", {})
|
|
2083
|
+
if not isinstance(fields, dict): errors.append("rawAuditFields must be an object")
|
|
2084
|
+
safe_fields = {}
|
|
2085
|
+
for key, value in (fields.items() if isinstance(fields, dict) else []):
|
|
2086
|
+
if re.fullmatch(r"[A-Za-z][A-Za-z0-9_.:-]{0,79}", str(key)) and not re.search(r"path|secret|token|password|cookie|header|body|html", str(key), re.I) and isinstance(value, (str, int, float, bool)) and not (isinstance(value, str) and re.search(r"(?:^|/)(?:home|Users|tmp|var|private)/", value)):
|
|
2087
|
+
safe_fields[str(key)] = str(value)[:240] if isinstance(value, str) else value
|
|
2088
|
+
backfill = payload.get("seoBackfill") if isinstance(payload.get("seoBackfill"), dict) else {}
|
|
2089
|
+
report = {
|
|
2090
|
+
"schemaVersion": "maggie-marketplace-enrichment.v1", "candidateId": candidate_id,
|
|
2091
|
+
"status": "passed" if not errors else "failed", "passed": not errors, "errors": errors,
|
|
2092
|
+
"candidate": {"source": str(candidate.get("source") or "")[:120], "market": str(candidate.get("market") or "global")[:20], "locale": str(candidate.get("locale") or "en")[:20]},
|
|
2093
|
+
"crawl": {"status": crawl.get("status"), "pages": crawl.get("pages", 0) if isinstance(crawl.get("pages", 0), int) else 0, "error": crawl.get("error")},
|
|
2094
|
+
"audit": {"status": audit.get("status"), "fields": safe_fields, "error": audit.get("error")},
|
|
2095
|
+
"snapshot": {"status": snapshot.get("status"), "revision": str(snapshot.get("revision") or "")[:120], "error": snapshot.get("error")},
|
|
2096
|
+
"seoBackfill": {"status": backfill.get("status"), "updated": backfill.get("updated", 0) if isinstance(backfill.get("updated", 0), int) else 0, "error": backfill.get("error")},
|
|
2097
|
+
"retry": {"allowed": bool(errors), "reason": ";".join(errors)[:240] if errors else None},
|
|
2098
|
+
"privacy": {"rawHtmlIncluded": False, "responseBodiesIncluded": False, "localPathsIncluded": False, "secretsRedacted": True},
|
|
2099
|
+
}
|
|
2100
|
+
output = args.output.resolve() if args.output else args.project.resolve() / "docs" / "marketplace-enrichment.json"
|
|
2101
|
+
output.parent.mkdir(parents=True, exist_ok=True); output.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
2102
|
+
print(json.dumps({"status": report["status"], "candidateId": candidate_id, "report": str(output), "retry": report["retry"]}, ensure_ascii=False)); return 0 if not errors else 1
|
|
2103
|
+
|
|
2104
|
+
|
|
2064
2105
|
def main() -> int:
|
|
2065
2106
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
2066
2107
|
parser.add_argument("--marketplace", type=Path, default=Path("marketplace"))
|
|
@@ -2130,6 +2171,11 @@ def main() -> int:
|
|
|
2130
2171
|
apply.add_argument("--project", type=Path, default=Path.cwd())
|
|
2131
2172
|
apply.add_argument("--allow-draft", action="store_true")
|
|
2132
2173
|
apply.set_defaults(func=apply_template)
|
|
2174
|
+
enrichment = sub.add_parser("enrichment-report", help="normalize safe candidate crawl and audit evidence")
|
|
2175
|
+
enrichment.add_argument("--project", type=Path, default=Path.cwd())
|
|
2176
|
+
enrichment.add_argument("--input", type=Path, required=True)
|
|
2177
|
+
enrichment.add_argument("--output", type=Path)
|
|
2178
|
+
enrichment.set_defaults(func=enrichment_report)
|
|
2133
2179
|
args = parser.parse_args()
|
|
2134
2180
|
try:
|
|
2135
2181
|
return int(args.func(args) or 0)
|
|
@@ -56,6 +56,8 @@ def validate_project(project: Path, environment: str) -> dict:
|
|
|
56
56
|
|
|
57
57
|
|
|
58
58
|
IDENTITY_SCHEMA = "maggie-database-identity.v1"
|
|
59
|
+
PREFLIGHT_SCHEMA = "maggie-migration-preflight.v1"
|
|
60
|
+
SAFE_NAME = re.compile(r"^[A-Za-z][A-Za-z0-9_.:-]{0,119}$")
|
|
59
61
|
|
|
60
62
|
|
|
61
63
|
def _identity_fingerprint(identity: dict) -> str:
|
|
@@ -119,7 +121,48 @@ def identity_main(argv: list[str]) -> int:
|
|
|
119
121
|
return 0 if result["passed"] else 1
|
|
120
122
|
|
|
121
123
|
|
|
124
|
+
def preflight_main(argv: list[str]) -> int:
|
|
125
|
+
parser = argparse.ArgumentParser(description="Validate secret-free migration privilege, column, and constraint evidence.")
|
|
126
|
+
parser.add_argument("--evidence", required=True)
|
|
127
|
+
parser.add_argument("--output")
|
|
128
|
+
args = parser.parse_args(argv)
|
|
129
|
+
errors: list[str] = []
|
|
130
|
+
try:
|
|
131
|
+
payload = json.loads(Path(args.evidence).read_text(encoding="utf-8"))
|
|
132
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
133
|
+
payload = {}; errors.append(f"cannot read migration preflight evidence: {error}")
|
|
134
|
+
if not isinstance(payload, dict): payload = {}; errors.append("preflight evidence must be an object")
|
|
135
|
+
if payload.get("schemaVersion") != PREFLIGHT_SCHEMA: errors.append(f"schemaVersion must be {PREFLIGHT_SCHEMA}")
|
|
136
|
+
if payload.get("environment") not in {"development", "staging", "production"}: errors.append("environment is invalid")
|
|
137
|
+
privileges = payload.get("privileges") if isinstance(payload.get("privileges"), dict) else {}
|
|
138
|
+
for field in ("canCreateTable", "canAlterTable", "canCreateIndex", "passed"):
|
|
139
|
+
if privileges.get(field) is not True: errors.append(f"privileges.{field} must be true")
|
|
140
|
+
columns = payload.get("requiredColumns")
|
|
141
|
+
if not isinstance(columns, list) or not columns: errors.append("requiredColumns must not be empty")
|
|
142
|
+
for index, item in enumerate(columns if isinstance(columns, list) else []):
|
|
143
|
+
if not isinstance(item, dict) or not SAFE_NAME.fullmatch(str(item.get("table") or "")) or not SAFE_NAME.fullmatch(str(item.get("column") or "")) or item.get("present") is not True:
|
|
144
|
+
errors.append(f"requiredColumns[{index}] must identify a present table column")
|
|
145
|
+
constraints = payload.get("uniqueConstraints")
|
|
146
|
+
if not isinstance(constraints, list) or not constraints: errors.append("uniqueConstraints must not be empty")
|
|
147
|
+
for index, item in enumerate(constraints if isinstance(constraints, list) else []):
|
|
148
|
+
if not isinstance(item, dict) or not SAFE_NAME.fullmatch(str(item.get("table") or "")) or not isinstance(item.get("columns"), list) or not item["columns"] or any(not SAFE_NAME.fullmatch(str(value)) for value in item["columns"]) or item.get("present") is not True:
|
|
149
|
+
errors.append(f"uniqueConstraints[{index}] must identify a present unique constraint")
|
|
150
|
+
idempotency = payload.get("idempotency") if isinstance(payload.get("idempotency"), dict) else {}
|
|
151
|
+
if idempotency.get("safe") is not True or idempotency.get("onConflictHandled") is not True: errors.append("idempotency must be safe and handle conflicts")
|
|
152
|
+
migration = payload.get("migration") if isinstance(payload.get("migration"), dict) else {}
|
|
153
|
+
for field in ("currentVersion", "targetVersion"):
|
|
154
|
+
if not SAFE_NAME.fullmatch(str(migration.get(field) or "")): errors.append(f"migration.{field} must be a safe identifier")
|
|
155
|
+
if migration.get("currentVersion") == migration.get("targetVersion"): errors.append("migration targetVersion must advance")
|
|
156
|
+
if migration.get("passed") is not True: errors.append("migration evidence did not pass")
|
|
157
|
+
result = {"schemaVersion": PREFLIGHT_SCHEMA, "environment": payload.get("environment"), "status": "passed" if not errors else "failed", "passed": not errors, "errors": errors, "checks": {"privileges": True, "requiredColumns": True, "uniqueConstraints": True, "idempotency": True, "migration": True}, "mutation": "not executed"}
|
|
158
|
+
if args.output:
|
|
159
|
+
output = Path(args.output); output.parent.mkdir(parents=True, exist_ok=True); output.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
|
|
160
|
+
print(json.dumps(result, indent=2)); return 0 if not errors else 1
|
|
161
|
+
|
|
162
|
+
|
|
122
163
|
def main() -> int:
|
|
164
|
+
if sys.argv[1:2] == ["preflight"]:
|
|
165
|
+
return preflight_main(sys.argv[2:])
|
|
123
166
|
if sys.argv[1:2] == ["identity"]:
|
|
124
167
|
return identity_main(sys.argv[2:])
|
|
125
168
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
@@ -7,7 +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 urllib.parse import quote, urljoin, urlsplit
|
|
10
|
+
from urllib.parse import quote, urljoin, urlsplit, urlparse
|
|
11
11
|
from urllib.request import Request, urlopen
|
|
12
12
|
|
|
13
13
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
|
|
@@ -1148,6 +1148,85 @@ def cmd_variant_render_audit(args):
|
|
|
1148
1148
|
result={"schemaVersion":"1.0","generatedAt":NOW(),"environment":args.environment,"status":"passed" if not errors else "failed","variants":results,"errors":errors,"passed":not errors}
|
|
1149
1149
|
out=project/"docs"/"service-variant-render-audit.json"; out.parent.mkdir(parents=True,exist_ok=True); out.write_text(json.dumps(result,indent=2)+"\n",encoding="utf-8")
|
|
1150
1150
|
print(json.dumps({**result,"report":str(out)},indent=2)); return 0 if not errors else 1
|
|
1151
|
+
SAFE_WORKER_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$")
|
|
1152
|
+
SAFE_EVIDENCE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/=-]{0,159}$")
|
|
1153
|
+
RESOLVER_OUTCOMES = {"resolved", "resolved-no-menu", "not-found", "blocked", "timeout", "unavailable"}
|
|
1154
|
+
|
|
1155
|
+
|
|
1156
|
+
def cmd_worker_health(args):
|
|
1157
|
+
"""Validate bounded queue, lease, retry, timeout, and cancellation evidence."""
|
|
1158
|
+
try:
|
|
1159
|
+
payload = json.loads(Path(args.evidence).resolve().read_text(encoding="utf-8"))
|
|
1160
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
1161
|
+
print(json.dumps({"status": "failed", "errors": [f"cannot read worker evidence: {exc}"]}, indent=2))
|
|
1162
|
+
return 1
|
|
1163
|
+
errors = []
|
|
1164
|
+
if payload.get("schemaVersion") != "maggie-booking-worker-health-input.v1": errors.append("schemaVersion is unsupported")
|
|
1165
|
+
if payload.get("environment") not in {"development", "staging", "production"}: errors.append("environment is invalid")
|
|
1166
|
+
worker_id = str(payload.get("workerId") or "")
|
|
1167
|
+
if not SAFE_WORKER_ID.fullmatch(worker_id): errors.append("workerId must be a safe identifier")
|
|
1168
|
+
queue = payload.get("queue") if isinstance(payload.get("queue"), dict) else {}
|
|
1169
|
+
for field in ("maxDepth", "maxConcurrency"):
|
|
1170
|
+
if not isinstance(queue.get(field), int) or isinstance(queue.get(field), bool) or queue[field] < 1: errors.append(f"queue.{field} must be a positive integer")
|
|
1171
|
+
if queue.get("bounded") is not True: errors.append("queue must be bounded")
|
|
1172
|
+
leases = payload.get("leases") if isinstance(payload.get("leases"), dict) else {}
|
|
1173
|
+
if leases.get("enabled") is not True or not isinstance(leases.get("ttlSeconds"), int) or leases.get("ttlSeconds", 0) < 1: errors.append("leases must have a positive TTL")
|
|
1174
|
+
if leases.get("staleReclaimed") is not True: errors.append("stale leases must be reclaimed")
|
|
1175
|
+
timeouts = payload.get("timeouts") if isinstance(payload.get("timeouts"), dict) else {}
|
|
1176
|
+
for field in ("jobSeconds", "providerSeconds"):
|
|
1177
|
+
if not isinstance(timeouts.get(field), int) or isinstance(timeouts.get(field), bool) or timeouts[field] < 1: errors.append(f"timeouts.{field} must be positive")
|
|
1178
|
+
retry = payload.get("retry") if isinstance(payload.get("retry"), dict) else {}
|
|
1179
|
+
if not isinstance(retry.get("maxAttempts"), int) or isinstance(retry.get("maxAttempts"), bool) or retry["maxAttempts"] < 0: errors.append("retry.maxAttempts must be non-negative")
|
|
1180
|
+
if retry.get("backoff") not in {"fixed", "exponential", "exponential-jitter"}: errors.append("retry.backoff must be an approved strategy")
|
|
1181
|
+
cancellation = payload.get("cancellation") if isinstance(payload.get("cancellation"), dict) else {}
|
|
1182
|
+
if cancellation.get("supported") is not True or cancellation.get("cleanupVerified") is not True: errors.append("cancellation cleanup is not verified")
|
|
1183
|
+
health = payload.get("health") if isinstance(payload.get("health"), dict) else {}
|
|
1184
|
+
if not isinstance(health.get("path"), str) or not health["path"].startswith("/") or health.get("passed") is not True: errors.append("health endpoint did not pass")
|
|
1185
|
+
if payload.get("orphanedJobs", 0) != 0: errors.append("orphanedJobs must be zero")
|
|
1186
|
+
report = {"schemaVersion": "maggie-booking-worker-health.v1", "environment": payload.get("environment"), "workerId": worker_id, "status": "passed" if not errors else "failed", "passed": not errors, "errors": errors, "checks": {"boundedQueue": True, "leases": True, "timeouts": True, "retryBackoff": True, "cancellationCleanup": True, "healthEndpoint": True}}
|
|
1187
|
+
project = root(args)
|
|
1188
|
+
output = Path(args.output) if args.output else project / "docs/booking-worker-health.json"
|
|
1189
|
+
if not output.is_absolute(): output = project / output
|
|
1190
|
+
output = output.resolve()
|
|
1191
|
+
output.parent.mkdir(parents=True, exist_ok=True); output.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
|
|
1192
|
+
print(json.dumps({"status": report["status"], "report": str(output), "errors": errors}, indent=2)); return 0 if not errors else 1
|
|
1193
|
+
|
|
1194
|
+
|
|
1195
|
+
def cmd_resolver_audit(args):
|
|
1196
|
+
"""Classify booking URL outcomes using safe canonical URL evidence."""
|
|
1197
|
+
try:
|
|
1198
|
+
payload = json.loads(Path(args.evidence).resolve().read_text(encoding="utf-8"))
|
|
1199
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
1200
|
+
print(json.dumps({"status": "failed", "errors": [f"cannot read resolver evidence: {exc}"]}, indent=2)); return 1
|
|
1201
|
+
errors = []
|
|
1202
|
+
if payload.get("schemaVersion") != "maggie-booking-resolver-input.v1": errors.append("schemaVersion is unsupported")
|
|
1203
|
+
observations = payload.get("observations")
|
|
1204
|
+
if not isinstance(observations, list) or not observations or len(observations) > 200: errors.append("observations must contain 1–200 items")
|
|
1205
|
+
safe = []
|
|
1206
|
+
for index, item in enumerate(observations if isinstance(observations, list) else []):
|
|
1207
|
+
prefix = f"observations[{index}]"
|
|
1208
|
+
if not isinstance(item, dict): errors.append(f"{prefix} must be an object"); continue
|
|
1209
|
+
service_id = str(item.get("serviceId") or ""); outcome = item.get("outcome")
|
|
1210
|
+
if not SAFE_WORKER_ID.fullmatch(service_id): errors.append(f"{prefix}.serviceId must be a safe identifier")
|
|
1211
|
+
if outcome not in RESOLVER_OUTCOMES: errors.append(f"{prefix}.outcome is unsupported")
|
|
1212
|
+
canonical = item.get("canonicalUrl")
|
|
1213
|
+
if outcome in {"resolved", "resolved-no-menu"}:
|
|
1214
|
+
parsed = urlparse(str(canonical or ""))
|
|
1215
|
+
if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password: errors.append(f"{prefix}.canonicalUrl must be a public HTTPS URL")
|
|
1216
|
+
elif canonical: errors.append(f"{prefix}.canonicalUrl is only allowed for resolved outcomes")
|
|
1217
|
+
evidence = item.get("evidenceIds")
|
|
1218
|
+
if not isinstance(evidence, list) or not evidence or any(not SAFE_EVIDENCE_ID.fullmatch(str(value)) for value in evidence[:20]): errors.append(f"{prefix}.evidenceIds must contain safe identifiers")
|
|
1219
|
+
if not isinstance(item.get("durationMs"), int) or isinstance(item.get("durationMs"), bool) or item["durationMs"] < 0: errors.append(f"{prefix}.durationMs must be non-negative")
|
|
1220
|
+
safe.append({"serviceId": service_id, "outcome": outcome, "canonicalUrl": canonical if outcome in {"resolved", "resolved-no-menu"} else None, "evidenceIds": [str(value) for value in evidence[:20]] if isinstance(evidence, list) else [], "durationMs": item.get("durationMs")})
|
|
1221
|
+
counts = {outcome: sum(item.get("outcome") == outcome for item in safe) for outcome in sorted(RESOLVER_OUTCOMES)}
|
|
1222
|
+
report = {"schemaVersion": "maggie-booking-resolver.v1", "status": "passed" if not errors else "failed", "passed": not errors, "errors": errors, "counts": counts, "observations": safe, "privacy": {"rawResponsesIncluded": False, "credentialsIncluded": False}}
|
|
1223
|
+
project = root(args)
|
|
1224
|
+
output = Path(args.output) if args.output else project / "docs/booking-resolver-audit.json"
|
|
1225
|
+
if not output.is_absolute(): output = project / output
|
|
1226
|
+
output = output.resolve(); output.parent.mkdir(parents=True, exist_ok=True); output.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
|
|
1227
|
+
print(json.dumps({"status": report["status"], "report": str(output), "counts": counts, "errors": errors}, indent=2)); return 0 if not errors else 1
|
|
1228
|
+
|
|
1229
|
+
|
|
1151
1230
|
def main():
|
|
1152
1231
|
p=argparse.ArgumentParser(); sub=p.add_subparsers(dest="command",required=True)
|
|
1153
1232
|
for name in ("import","sync","run"):
|
|
@@ -1173,6 +1252,8 @@ def main():
|
|
|
1173
1252
|
q=sub.add_parser("variant-audit"); q.add_argument("--project",default="."); q.add_argument("--variants",required=True,help="draft service-page variant JSON"); q.add_argument("--as-of",help="ISO timestamp for expiry checks"); q.add_argument("--environment",choices=("development","staging","production"),default="staging")
|
|
1174
1253
|
q=sub.add_parser("category-visual-audit"); q.add_argument("--project",default="."); q.add_argument("--screenshot-dir",required=True); q.add_argument("--min-bytes",type=int,default=5000); q.add_argument("--environment",choices=("development","staging","production"),default="staging")
|
|
1175
1254
|
q=sub.add_parser("variant-render-audit"); q.add_argument("--project",default="."); q.add_argument("--variants",required=True); q.add_argument("--rendered-dir",required=True); q.add_argument("--environment",choices=("development","staging","production"),default="staging")
|
|
1255
|
+
q=sub.add_parser("worker-health"); q.add_argument("--project",default="."); q.add_argument("--evidence",required=True); q.add_argument("--output")
|
|
1256
|
+
q=sub.add_parser("resolver-audit"); q.add_argument("--project",default="."); q.add_argument("--evidence",required=True); q.add_argument("--output")
|
|
1176
1257
|
for name in ("validate","inspect","status"):
|
|
1177
1258
|
q=sub.add_parser(name); q.add_argument("--project",default=".")
|
|
1178
1259
|
a=p.parse_args(); project=root(a)
|
|
@@ -1198,6 +1279,8 @@ def main():
|
|
|
1198
1279
|
if a.command=="variant-audit": return cmd_variant_audit(a)
|
|
1199
1280
|
if a.command=="category-visual-audit": return cmd_category_visual_audit(a)
|
|
1200
1281
|
if a.command=="variant-render-audit": return cmd_variant_render_audit(a)
|
|
1282
|
+
if a.command=="worker-health": return cmd_worker_health(a)
|
|
1283
|
+
if a.command=="resolver-audit": return cmd_resolver_audit(a)
|
|
1201
1284
|
if a.command=="inspect": return cmd_inspect(a)
|
|
1202
1285
|
if a.command=="status": return cmd_status(a)
|
|
1203
1286
|
data=load(project) if path(project).exists() else {}; errors=validate(data); print(json.dumps({"status":"passed" if not errors else "failed","errors":errors,"services":len(data.get("services",[]))},indent=2)); return 0 if not errors else 1
|