@topy-ai/maggie 0.7.4 → 0.7.5
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 +14 -6
- package/README.zh-TW.md +5 -1
- package/bin/maggie.js +18 -2
- package/bundled-contracts/maggie-deployment/release-runner-v1.json +14 -0
- package/bundled-contracts/maggiedash/README.md +6 -0
- package/bundled-contracts/maggiedash/agent-content-v1.json +15 -0
- package/bundled-contracts/maggiedash/section-identity-v1.json +14 -0
- package/bundled-contracts/maggiedash/translation-cache-policy-v1.json +15 -0
- package/bundled-skills/maggie-dash/SKILL.md +48 -0
- package/bundled-skills/maggie-deployment/SKILL.md +20 -2
- package/bundled-skills/maggie-ops/SKILL.md +17 -0
- package/bundled-skills/maggie-seo-geo/SKILL.md +16 -0
- package/bundled-templates/maggiedash/section-registry.json +12 -0
- package/bundled-tools/clis/maggie_agent_content.py +75 -0
- package/bundled-tools/clis/maggie_dash.py +44 -0
- package/bundled-tools/clis/maggie_deployment.py +51 -0
- package/bundled-tools/clis/maggie_ops.py +23 -0
- package/bundled-tools/clis/maggie_verification.py +33 -0
- package/bundled-tools/runtime/agent_runtime.py +38 -0
- package/bundled-tools/runtime/maggie_dash_store.py +51 -0
- package/bundled-tools/runtime/maggie_sections.py +143 -0
- package/bundled-tools/runtime/restart_gate.py +22 -0
- package/bundled-tools/runtime/surface_coverage.py +48 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -92,6 +92,9 @@ maggie bootstrap interview | phase ...
|
|
|
92
92
|
maggie dash install | init | status | migrate | cms ...
|
|
93
93
|
maggie dash transition ... # explicit content approval transition
|
|
94
94
|
maggie dash variant ... # service variant create/review/preview/publish
|
|
95
|
+
maggie dash sections ... # validate/catalogue/identity keys
|
|
96
|
+
maggie agent-content write ... # host-authorized content bridge
|
|
97
|
+
maggie verification coverage ... # changed surface/locale evidence gate
|
|
95
98
|
maggie clone ... # authorized homepage capture
|
|
96
99
|
maggie design ... # authorized interior-page design
|
|
97
100
|
maggie clone-to-template ... # URL → validated marketplace template
|
|
@@ -191,8 +194,8 @@ artifact schemas.
|
|
|
191
194
|
Recommended upgrade sequence for the current release:
|
|
192
195
|
|
|
193
196
|
```bash
|
|
194
|
-
npx @topy-ai/maggie@0.7.
|
|
195
|
-
npx @topy-ai/maggie@0.7.
|
|
197
|
+
npx @topy-ai/maggie@0.7.5 update --project . --force
|
|
198
|
+
npx @topy-ai/maggie@0.7.5 cleanup --project .
|
|
196
199
|
```
|
|
197
200
|
|
|
198
201
|
Maintainers should pass npm credentials through the repository helper, never
|
|
@@ -202,15 +205,20 @@ as a command-line argument:
|
|
|
202
205
|
node scripts/publish-npm.mjs --maggie-env-file ../.env
|
|
203
206
|
```
|
|
204
207
|
|
|
205
|
-
The 0.7.
|
|
208
|
+
The 0.7.5 workflow adds the installable MaggieDash admin distribution and
|
|
206
209
|
audited CMS operations (`cms revisions`,
|
|
207
210
|
`trash`, `restore`, `schedule`, `publish-due`, `duplicate`, `redirect`, and
|
|
208
211
|
signed `preview`). It also adds a read-only MaggieDash `--diff`/`--dry-run`
|
|
209
212
|
migration inventory, body-only dashboard prop validation, dialog accessibility
|
|
210
213
|
contract checks, 404-only redirect policy, trash/scheduler/preview invariants,
|
|
211
|
-
and a shared first-party traffic filter for every measurement channel.
|
|
212
|
-
|
|
213
|
-
|
|
214
|
+
and a shared first-party traffic filter for every measurement channel. It also
|
|
215
|
+
adds stable section identities and reusable section presets, a machine-readable
|
|
216
|
+
MaggieDash section registry, a short-lived agent content bridge, restart gates
|
|
217
|
+
for out-of-band translation writes, deployment release-runner generation,
|
|
218
|
+
disk-versus-manifest skill inventory checks, and changed-surface/locale
|
|
219
|
+
verification. The release retains the existing service matching,
|
|
220
|
+
localization, seed-manifest, lockfile/analytics, sitemap, deployment and
|
|
221
|
+
rollback workflows.
|
|
214
222
|
|
|
215
223
|
## MaggieDash lifecycle
|
|
216
224
|
|
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.
|
|
11
|
+
npx @topy-ai/maggie@0.7.5 init --agent all
|
|
12
12
|
npx @topy-ai/maggie doctor --project .
|
|
13
13
|
```
|
|
14
14
|
|
|
@@ -25,6 +25,10 @@ maggie doctor --project . --require-bootstrap --strict
|
|
|
25
25
|
deployment、memory、feedback 和 MaggieDash。內容先 draft/review,外部寫入、
|
|
26
26
|
publish 與 production deployment 需要明確確認。
|
|
27
27
|
|
|
28
|
+
MaggieDash 也提供穩定 section identity、可重用 section arrangement、機器可讀
|
|
29
|
+
section registry、短期 agent content bridge,以及 translation out-of-band write
|
|
30
|
+
後的 restart gate。`maggie doctor` 會比較 install manifest 與磁碟上的實際 skills。
|
|
31
|
+
|
|
28
32
|
新版 release gates:
|
|
29
33
|
|
|
30
34
|
```bash
|
package/bin/maggie.js
CHANGED
|
@@ -66,9 +66,11 @@ Usage:
|
|
|
66
66
|
maggie doctor [--project PATH]
|
|
67
67
|
maggie bootstrap interview [project]
|
|
68
68
|
maggie dash init|install|status|migrate|transition|variant|cms --project PATH [options]
|
|
69
|
+
maggie dash sections <validate|prompt|keys> [options]
|
|
69
70
|
maggie dash status --project PATH
|
|
70
71
|
maggie dash migrate --project PATH --confirm
|
|
71
72
|
maggie content FILE --source PROVIDER --project PATH --confirm
|
|
73
|
+
maggie agent-content write --url URL --token-env ENV --payload FILE
|
|
72
74
|
maggie bootstrap phase <start|pass|fail|status> <phase> [project]
|
|
73
75
|
maggie remove [SKILL ...] [--project PATH] [--agent codex|claude|all]
|
|
74
76
|
maggie cleanup --project PATH [--confirm]
|
|
@@ -117,6 +119,7 @@ Usage:
|
|
|
117
119
|
maggie site-audit URL --crawl --save-baseline FILE --reviewer NAME
|
|
118
120
|
maggie site-audit URL --crawl --baseline FILE
|
|
119
121
|
maggie browser-audit URL --browse PATH --output DIR --required SELECTOR [--sticky SELECTOR]
|
|
122
|
+
maggie verification coverage --contract FILE --evidence FILE
|
|
120
123
|
maggie ops audit|preflight|verify|lockfiles|seed-manifest --project PATH
|
|
121
124
|
maggie ops preflight --project PATH --write
|
|
122
125
|
|
|
@@ -320,11 +323,22 @@ function doctor(args) {
|
|
|
320
323
|
["claude-skills", existsSync(join(root, ".claude", "skills"))],
|
|
321
324
|
];
|
|
322
325
|
for (const [name, passed] of checks) console.log(`${passed ? "PASS" : "INFO"} ${name}`);
|
|
326
|
+
const manifestPath = join(root, STATE_DIR, "install.json");
|
|
327
|
+
let manifest = null;
|
|
328
|
+
try { if (existsSync(manifestPath)) manifest = JSON.parse(readFileSync(manifestPath, "utf8")); } catch { manifest = null; }
|
|
329
|
+
const expected = Array.isArray(manifest?.skills) ? [...new Set(manifest.skills)] : null;
|
|
323
330
|
const installed = [];
|
|
331
|
+
const installedNames = new Set();
|
|
324
332
|
for (const agentRoot of [join(root, ".agents"), join(root, ".claude")]) {
|
|
325
|
-
for (const skill of SKILL_NAMES) if (existsSync(join(agentRoot, "skills", skill, "SKILL.md")))
|
|
333
|
+
for (const skill of SKILL_NAMES) if (existsSync(join(agentRoot, "skills", skill, "SKILL.md"))) {
|
|
334
|
+
installed.push(`${agentRoot}/${skill}`);
|
|
335
|
+
installedNames.add(skill);
|
|
336
|
+
}
|
|
326
337
|
}
|
|
327
|
-
|
|
338
|
+
const missing = expected ? expected.filter((skill) => !installedNames.has(skill)) : [];
|
|
339
|
+
const inventoryState = expected && missing.length ? "INCOMPLETE" : "PASS";
|
|
340
|
+
console.log(`${inventoryState} skill-inventory manifest=${expected?.length ?? "unknown"} on-disk=${installedNames.size}${missing.length ? ` missing=${missing.join(",")}` : ""}`);
|
|
341
|
+
console.log(`INFO installed-skills=${installedNames.size}`);
|
|
328
342
|
console.log("INFO doctor is diagnostic; bootstrap completion remains a project decision gate.");
|
|
329
343
|
}
|
|
330
344
|
|
|
@@ -394,7 +408,9 @@ try {
|
|
|
394
408
|
else if (command === "feedback") workflowCli("maggie_feedback.py", args);
|
|
395
409
|
else if (command === "site-audit") workflowCli("site_audit.py", args);
|
|
396
410
|
else if (command === "browser-audit") workflowCli("maggie_browser_audit.py", args);
|
|
411
|
+
else if (command === "verification") workflowCli("maggie_verification.py", args);
|
|
397
412
|
else if (command === "dash") workflowCli("maggie_dash.py", args);
|
|
413
|
+
else if (command === "agent-content") workflowCli("maggie_agent_content.py", args);
|
|
398
414
|
else if (command === "content") workflowCli("maggie_content.py", args);
|
|
399
415
|
else if (command === "init" || command === "install") install(args);
|
|
400
416
|
else if (command === "update") update(args);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://maggiedash.noblox.app/contracts/release-runner-v1.schema.json",
|
|
4
|
+
"title": "Maggie deployment release runner v1",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["schemaVersion", "orderedSteps", "retention"],
|
|
7
|
+
"properties": {
|
|
8
|
+
"schemaVersion": {"const": "maggie-release-runner.v1"},
|
|
9
|
+
"orderedSteps": {"const": ["install", "typecheck", "migrate", "build", "carry-agent-state", "switch", "restart", "verify", "prune"]},
|
|
10
|
+
"retention": {"const": "exactly-current-plus-one-rollback"},
|
|
11
|
+
"mutation": {"enum": ["reviewable-script", "executed-after-confirmation"]}
|
|
12
|
+
},
|
|
13
|
+
"additionalProperties": false
|
|
14
|
+
}
|
|
@@ -18,6 +18,12 @@ frontend framework.
|
|
|
18
18
|
self/chain safety for canonical route redirects;
|
|
19
19
|
- [`telemetry-policy-v1.json`](telemetry-policy-v1.json): first-party/toolchain
|
|
20
20
|
traffic exclusion for every measurement channel.
|
|
21
|
+
- [`agent-content-v1.json`](agent-content-v1.json): short-lived host bridge for
|
|
22
|
+
validated agent content writes without database credentials;
|
|
23
|
+
- [`section-identity-v1.json`](section-identity-v1.json): stable section IDs
|
|
24
|
+
for translation keys and ordered migration;
|
|
25
|
+
- [`translation-cache-policy-v1.json`](translation-cache-policy-v1.json):
|
|
26
|
+
restart-after-out-of-band-write evidence.
|
|
21
27
|
|
|
22
28
|
MaggieDash owns these contracts. Provider adapters may add namespaced metadata,
|
|
23
29
|
but they may not change the required identity, status, provenance, or approval
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://maggiedash.noblox.app/contracts/agent-content-v1.schema.json",
|
|
4
|
+
"title": "MaggieDash agent content bridge v1",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["method", "authorization", "tokenLifetime", "databaseCredentials"],
|
|
7
|
+
"properties": {
|
|
8
|
+
"method": {"const": "POST"},
|
|
9
|
+
"authorization": {"const": "Bearer short-lived session token"},
|
|
10
|
+
"tokenLifetime": {"const": "terminal session"},
|
|
11
|
+
"databaseCredentials": {"const": "never exposed to agent"},
|
|
12
|
+
"writes": {"type": "array", "items": {"enum": ["validated content operation", "revision", "audit event"]}}
|
|
13
|
+
},
|
|
14
|
+
"additionalProperties": false
|
|
15
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://maggiedash.noblox.app/contracts/section-identity-v1.schema.json",
|
|
4
|
+
"title": "MaggieDash stable section identity v1",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["schemaVersion", "keyPattern", "fallback", "migrationOrder"],
|
|
7
|
+
"properties": {
|
|
8
|
+
"schemaVersion": {"const": "maggiedash-section-identity.v1"},
|
|
9
|
+
"keyPattern": {"const": "page.<id>.sections.<section-id>.<field>"},
|
|
10
|
+
"fallback": {"const": "array-index-until-migrated"},
|
|
11
|
+
"migrationOrder": {"const": "longest-old-prefix-first"}
|
|
12
|
+
},
|
|
13
|
+
"additionalProperties": false
|
|
14
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://maggiedash.noblox.app/contracts/translation-cache-policy-v1.schema.json",
|
|
4
|
+
"title": "MaggieDash translation cache policy v1",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["schemaVersion", "outOfBandWrite", "restartRequired", "restartCommand", "verificationAfterRestart"],
|
|
7
|
+
"properties": {
|
|
8
|
+
"schemaVersion": {"const": "maggie-restart-gate.v1"},
|
|
9
|
+
"outOfBandWrite": {"const": true},
|
|
10
|
+
"restartRequired": {"const": true},
|
|
11
|
+
"restartCommand": {"type": "string", "minLength": 1},
|
|
12
|
+
"verificationAfterRestart": {"const": true}
|
|
13
|
+
},
|
|
14
|
+
"additionalProperties": false
|
|
15
|
+
}
|
|
@@ -46,6 +46,21 @@ owns the framework route, email/password session middleware, database, media
|
|
|
46
46
|
storage, provider credentials, and `/api/maggie/*` adapter endpoints. Read the
|
|
47
47
|
MaggieDash host adapter contract before adding a new framework adapter.
|
|
48
48
|
|
|
49
|
+
For a built/deployed host, an agent must use the host's validated content
|
|
50
|
+
bridge rather than editing release files or receiving database credentials:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
maggie agent-content write \
|
|
54
|
+
--url https://example.test/api/maggie/agent-content.json \
|
|
55
|
+
--token-env MAGGIE_CONTENT_TOKEN --payload .maggie/content-operation.json
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The host mints a short-lived session token. The bridge uses `POST` with
|
|
59
|
+
`Authorization: Bearer`, delegates to the ordinary save/revision/audit path,
|
|
60
|
+
and returns 401/422 for invalid authority or content. Never put database
|
|
61
|
+
credentials or the short-lived token in the payload or generated reports. See
|
|
62
|
+
the [agent content contract](../../contracts/maggiedash/agent-content-v1.json).
|
|
63
|
+
|
|
49
64
|
## Workflow
|
|
50
65
|
|
|
51
66
|
1. Inspect the project with `maggie dash status --project PATH`.
|
|
@@ -125,6 +140,11 @@ maggie dash cms publish-due --project . --project-id local-project \
|
|
|
125
140
|
--reason "scheduled publish tick" --confirm
|
|
126
141
|
maggie dash cms duplicate --project . --project-id local-project --document-id <id> \
|
|
127
142
|
--new-id <new-id> --new-slug <new-slug> --reason "create draft" --confirm
|
|
143
|
+
maggie dash cms preset-save --project . --project-id local-project \
|
|
144
|
+
--preset-id treatment-page --name "Treatment page" \
|
|
145
|
+
--sections-file .maggie/sections.json --confirm
|
|
146
|
+
maggie dash cms preset-insert --project . --project-id local-project \
|
|
147
|
+
--preset-id treatment-page --existing-id hero-1 --reason "add arrangement" --confirm
|
|
128
148
|
maggie dash cms redirect --project . --project-id local-project --from-path /old --to-path /new \
|
|
129
149
|
--reason "canonical slug change" --confirm
|
|
130
150
|
maggie dash cms preview --project . --project-id local-project --document-id <id> \
|
|
@@ -142,3 +162,31 @@ are short-lived HMAC-signed tokens; preview responses must use a real preview
|
|
|
142
162
|
URL with `noindex`, `X-Robots-Tag: noindex`, and `Cache-Control: no-store`.
|
|
143
163
|
Never place the secret in source control or generated reports. See the
|
|
144
164
|
provider-neutral [MaggieDash contract set](../../contracts/maggiedash/README.md).
|
|
165
|
+
|
|
166
|
+
Reusable arrangements are stored without page-specific section IDs and expand
|
|
167
|
+
into ordinary editable sections with fresh IDs on every insert. Use the
|
|
168
|
+
machine-readable section catalogue before asking a model to plan copy:
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
maggie dash sections validate --registry templates/maggiedash/section-registry.json \
|
|
172
|
+
--sections-file .maggie/sections.json
|
|
173
|
+
maggie dash sections prompt --registry templates/maggiedash/section-registry.json
|
|
174
|
+
maggie dash sections keys --registry templates/maggiedash/section-registry.json \
|
|
175
|
+
--page-id <page-id> --sections-file .maggie/sections.json
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
The catalogue declares purpose, usage, placement, repeatability and layout
|
|
179
|
+
limits. Over-limit copy is an editor note; unknown types fail validation.
|
|
180
|
+
Translation keys use stable section IDs, with an array-index fallback only
|
|
181
|
+
until the ordered migration is complete.
|
|
182
|
+
|
|
183
|
+
After any script or direct adapter write to translation data, invalidate the
|
|
184
|
+
running process before verification. Record the restart and run the rendering
|
|
185
|
+
check afterward:
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
maggie ops restart-gate --manifest .maggie/translation-restart.json
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
This prevents an in-process cache window from turning correct database state
|
|
192
|
+
into temporarily stale pages and false verification failures.
|
|
@@ -63,11 +63,15 @@ Generate a reviewable VPS plan and configuration artifacts locally:
|
|
|
63
63
|
python3 tools/clis/maggie_deployment.py /path/to/project \
|
|
64
64
|
--vps-plan --domain example.co.uk --service example \
|
|
65
65
|
--release-root /var/www/example \
|
|
66
|
-
--plan-dir .maggie/deployment/vps-staging
|
|
66
|
+
--plan-dir .maggie/deployment/vps-staging \
|
|
67
|
+
--runner-output .maggie/deployment/vps-staging/release-runner.sh
|
|
67
68
|
```
|
|
68
69
|
|
|
69
70
|
The output contains only the immutable release/current layout, systemd unit,
|
|
70
|
-
Nginx reverse-proxy config, health commands
|
|
71
|
+
Nginx reverse-proxy config, health commands, rollback command, and an ordered
|
|
72
|
+
reviewable release runner. The runner keeps migrations before the symlink flip,
|
|
73
|
+
carries `.agents`/`.claude` state, restarts before verification, and prunes to
|
|
74
|
+
the current release plus one rollback candidate. It never
|
|
71
75
|
contains credentials and never SSHs, changes DNS, restarts systemd or deploys.
|
|
72
76
|
|
|
73
77
|
VPS plans include a bounded retention policy: keep two immutable releases,
|
|
@@ -136,6 +140,20 @@ MaggieDash schema compatibility, and live runtime security-header checks. It wri
|
|
|
136
140
|
`.maggie/release-preflight.json`; a failed gate blocks release. It never
|
|
137
141
|
deploys, migrates, publishes, or sends provider requests.
|
|
138
142
|
|
|
143
|
+
Generate a repeatable VPS runner as part of the plan. A deploy step that exists
|
|
144
|
+
only in an operator's shell history is not a release contract:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
python3 tools/clis/maggie_deployment.py /path/to/project \
|
|
148
|
+
--vps-plan --domain example.co.uk --service example \
|
|
149
|
+
--release-root /var/www/example \
|
|
150
|
+
--runner-output .maggie/deployment/release-runner.sh
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Review the generated runner before execution. Its order is install → typecheck
|
|
154
|
+
→ migrate → build → carry agent state → switch → restart → verify → prune;
|
|
155
|
+
retention keeps the current release and one rollback candidate.
|
|
156
|
+
|
|
139
157
|
Production additionally requires `docs/deployment-rollback-smoke.json` with
|
|
140
158
|
`passed: true`, `environment: production`, a non-empty `previousRelease`, and
|
|
141
159
|
`testedAt`. A rollback command in a plan is not proof that the rollback was
|
|
@@ -226,3 +226,20 @@ shared contract is in
|
|
|
226
226
|
[references/maggiedash-telemetry-contract.md](../../references/maggiedash-telemetry-contract.md);
|
|
227
227
|
it excludes only explicit tool/test markers and never infers identity from IP
|
|
228
228
|
or private fields.
|
|
229
|
+
|
|
230
|
+
## Agent runtime preflight
|
|
231
|
+
|
|
232
|
+
Before starting an agent from a dashboard, run the runtime preflight and show
|
|
233
|
+
its warnings to the operator. Terminal output must be stripped of ANSI escape
|
|
234
|
+
sequences before URL/code parsing. Headless hosts should use device auth and
|
|
235
|
+
state the account prerequisite before starting. Release installs and agent
|
|
236
|
+
conversation state must be carried outside the immutable release directory.
|
|
237
|
+
When continuing a conversation, pass the agent's resume subcommand before
|
|
238
|
+
other arguments; otherwise the CLI may interpret it as a new prompt.
|
|
239
|
+
|
|
240
|
+
```bash
|
|
241
|
+
maggie ops agent-preflight --output .maggie/agent-runtime-preflight.json
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
The preflight is advisory about the host environment but its warnings are
|
|
245
|
+
load-bearing operational evidence, not a claim that an agent login succeeded.
|
|
@@ -19,6 +19,22 @@ Static audit success
|
|
|
19
19
|
does not establish Google indexing, scroll behavior, responsive visibility,
|
|
20
20
|
or translation quality. Record those as unverified without separate evidence.
|
|
21
21
|
|
|
22
|
+
Choose verification from the changed surface. An admin-only change still needs
|
|
23
|
+
the admin routes; an API change needs the API responses; a localized change
|
|
24
|
+
needs every affected locale. Declare the matrix and validate explicit evidence
|
|
25
|
+
before release:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
maggie verification coverage \
|
|
29
|
+
--contract contracts/maggie-verification/surface-coverage-v1.json \
|
|
30
|
+
--evidence .maggie/surface-coverage.json
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The evidence must contain one observation for every declared surface/locale
|
|
34
|
+
pair with a status and `passed: true`. Public-page checks alone cannot close an
|
|
35
|
+
admin or API change, and one source-language rendering cannot close a
|
|
36
|
+
localized change.
|
|
37
|
+
|
|
22
38
|
Crawl reports include `summary.byLocaleTemplate` with total, passed, failed
|
|
23
39
|
and failed URLs per declared locale and template. Regions/scripts remain
|
|
24
40
|
distinct (for example en-GB versus en-US). A template is identified only when
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": "maggiedash-section-registry.v1",
|
|
3
|
+
"description": "The machine-readable section vocabulary used by page planners and editors.",
|
|
4
|
+
"sections": [
|
|
5
|
+
{"type": "hero", "purpose": "Says what the page is about and offers the one important action.", "usage": "First on the page; once.", "position": "opening", "repeatable": false, "fields": [{"name": "title", "usage": "Plain words a visitor would search for.", "limit": 70, "required": true}, {"name": "intro", "usage": "Who it is for and what it does.", "limit": 220}, {"name": "ctaLabel", "usage": "The action as a verb.", "limit": 30}]},
|
|
6
|
+
{"type": "prose", "purpose": "Explains a topic at length, optionally beside an image.", "usage": "Use in the body for explanation.", "position": "body", "repeatable": true, "fields": [{"name": "heading", "usage": "The argument in a sentence.", "limit": 90}, {"name": "paragraphs", "usage": "Two or three standalone paragraphs.", "limit": 400, "repeats": {"min": 1, "max": 4}}]},
|
|
7
|
+
{"type": "cards", "purpose": "Presents parallel points side by side.", "usage": "Use for steps or genuinely parallel points.", "position": "body", "repeatable": true, "fields": [{"name": "heading", "usage": "What the cards share.", "limit": 90}, {"name": "items", "usage": "The parallel points.", "limit": 220, "repeats": {"min": 3, "max": 3}}]},
|
|
8
|
+
{"type": "links", "purpose": "Sends the reader to related pages.", "usage": "Near the end after the page has done its job.", "position": "closing", "repeatable": true, "fields": [{"name": "heading", "usage": "What the links have in common.", "limit": 90}, {"name": "items", "usage": "Real related pages on this site.", "limit": 160, "repeats": {"min": 2, "max": 6}}]},
|
|
9
|
+
{"type": "faq", "purpose": "Answers questions a visitor would otherwise ask.", "usage": "Once near the end; use real visitor questions.", "position": "closing", "repeatable": false, "fields": [{"name": "heading", "usage": "The question topic.", "limit": 90}, {"name": "items", "usage": "Direct questions and answers.", "limit": 600, "repeats": {"min": 2, "max": 8}}]},
|
|
10
|
+
{"type": "cta", "purpose": "Makes the closing ask.", "usage": "Last on the page; once.", "position": "closing", "repeatable": false, "fields": [{"name": "heading", "usage": "The invitation in a sentence.", "limit": 90, "required": true}, {"name": "body", "usage": "What happens next.", "limit": 220}, {"name": "label", "usage": "The action as a verb.", "limit": 30, "required": true}]}
|
|
11
|
+
]
|
|
12
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Use a short-lived MaggieDash host token for validated content writes."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from urllib.error import HTTPError, URLError
|
|
12
|
+
from urllib.request import Request, urlopen
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
SENSITIVE_KEYS = {"databaseUrl", "databaseURL", "password", "secret", "token", "apiKey"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def validate_payload(value: object) -> dict:
|
|
19
|
+
if not isinstance(value, dict):
|
|
20
|
+
raise ValueError("payload must be a JSON object")
|
|
21
|
+
forbidden = sorted(SENSITIVE_KEYS.intersection(value))
|
|
22
|
+
if forbidden:
|
|
23
|
+
raise ValueError("payload must not contain credentials; use the short-lived token environment variable")
|
|
24
|
+
if not value.get("op"):
|
|
25
|
+
raise ValueError("payload.op is required")
|
|
26
|
+
return value
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def write(url: str, token_env: str, payload: dict, timeout: int = 30) -> dict:
|
|
30
|
+
token = os.environ.get(token_env, "")
|
|
31
|
+
if not token:
|
|
32
|
+
raise ValueError(f"{token_env} is not set")
|
|
33
|
+
request = Request(url, data=json.dumps(payload, ensure_ascii=False).encode("utf-8"), method="POST", headers={
|
|
34
|
+
"Accept": "application/json", "Content-Type": "application/json", "Authorization": f"Bearer {token}",
|
|
35
|
+
})
|
|
36
|
+
try:
|
|
37
|
+
with urlopen(request, timeout=timeout) as response:
|
|
38
|
+
raw = response.read().decode("utf-8", errors="replace")
|
|
39
|
+
status = response.status
|
|
40
|
+
except HTTPError as error:
|
|
41
|
+
raw = error.read().decode("utf-8", errors="replace")
|
|
42
|
+
status = error.code
|
|
43
|
+
except URLError as error:
|
|
44
|
+
raise RuntimeError("content host could not be reached") from error
|
|
45
|
+
try:
|
|
46
|
+
body = json.loads(raw) if raw else {}
|
|
47
|
+
except json.JSONDecodeError:
|
|
48
|
+
body = {"error": "host returned non-JSON"}
|
|
49
|
+
if isinstance(body, dict):
|
|
50
|
+
for key in SENSITIVE_KEYS:
|
|
51
|
+
body.pop(key, None)
|
|
52
|
+
return {"status": status, "passed": 200 <= status < 300, "response": body}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def main() -> int:
|
|
56
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
57
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
58
|
+
command = sub.add_parser("write", help="POST a validated content operation")
|
|
59
|
+
command.add_argument("--url", required=True)
|
|
60
|
+
command.add_argument("--token-env", default="MAGGIE_CONTENT_TOKEN")
|
|
61
|
+
command.add_argument("--payload", type=Path, required=True)
|
|
62
|
+
command.add_argument("--timeout", type=int, default=30)
|
|
63
|
+
args = parser.parse_args()
|
|
64
|
+
try:
|
|
65
|
+
payload = validate_payload(json.loads(args.payload.read_text(encoding="utf-8")))
|
|
66
|
+
result = write(args.url, args.token_env, payload, args.timeout)
|
|
67
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
68
|
+
return 0 if result["passed"] else 1
|
|
69
|
+
except (OSError, ValueError, RuntimeError) as error:
|
|
70
|
+
print(f"agent-content: {error}", file=sys.stderr)
|
|
71
|
+
return 2
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
if __name__ == "__main__":
|
|
75
|
+
raise SystemExit(main())
|
|
@@ -23,6 +23,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
|
|
|
23
23
|
from maggie_dash_store import MaggieDashStore # noqa: E402
|
|
24
24
|
from service_variants import ServiceVariantStore # noqa: E402
|
|
25
25
|
from maggie_dash_ui import load_and_validate # noqa: E402
|
|
26
|
+
from maggie_sections import catalogue, section_id_migration, validate_registry, copy_notes # noqa: E402
|
|
26
27
|
|
|
27
28
|
|
|
28
29
|
def project_root(args: argparse.Namespace) -> Path:
|
|
@@ -296,6 +297,13 @@ def command_cms(args: argparse.Namespace) -> int:
|
|
|
296
297
|
result = store.add_redirect(args.project_id, args.from_path, args.to_path, args.actor, args.reason, args.status_code)
|
|
297
298
|
elif args.cms_command == "preview":
|
|
298
299
|
result = store.issue_preview(args.project_id, args.document_id, args.secret, args.ttl)
|
|
300
|
+
elif args.cms_command == "preset-save":
|
|
301
|
+
sections = json.loads(Path(args.sections_file).resolve().read_text(encoding="utf-8"))
|
|
302
|
+
result = store.save_section_preset(args.project_id, args.preset_id, args.name, sections, args.actor, args.description)
|
|
303
|
+
elif args.cms_command == "preset-list":
|
|
304
|
+
result = store.list_section_presets(args.project_id)
|
|
305
|
+
elif args.cms_command == "preset-insert":
|
|
306
|
+
result = store.insert_section_preset(args.project_id, args.preset_id, args.existing_id, args.actor, args.reason)
|
|
299
307
|
else:
|
|
300
308
|
raise ValueError(f"unknown CMS command: {args.cms_command}")
|
|
301
309
|
emit(result)
|
|
@@ -310,6 +318,25 @@ def command_ui(args: argparse.Namespace) -> int:
|
|
|
310
318
|
return 0 if result["passed"] else 1
|
|
311
319
|
|
|
312
320
|
|
|
321
|
+
def command_sections(args: argparse.Namespace) -> int:
|
|
322
|
+
registry = json.loads(Path(args.registry).resolve().read_text(encoding="utf-8"))
|
|
323
|
+
checked = validate_registry(registry)
|
|
324
|
+
if not checked["passed"]:
|
|
325
|
+
emit(checked)
|
|
326
|
+
return 1
|
|
327
|
+
if args.sections_command == "prompt":
|
|
328
|
+
print(catalogue(registry))
|
|
329
|
+
return 0
|
|
330
|
+
if args.sections_command == "validate":
|
|
331
|
+
sections = json.loads(Path(args.sections_file).resolve().read_text(encoding="utf-8"))
|
|
332
|
+
result = {**checked, "copy": copy_notes(registry, sections)}
|
|
333
|
+
emit(result)
|
|
334
|
+
return 0 if result["copy"]["passed"] else 1
|
|
335
|
+
sections = json.loads(Path(args.sections_file).resolve().read_text(encoding="utf-8"))
|
|
336
|
+
emit(section_id_migration(args.page_id, sections))
|
|
337
|
+
return 0
|
|
338
|
+
|
|
339
|
+
|
|
313
340
|
def variant_store(args: argparse.Namespace) -> ServiceVariantStore:
|
|
314
341
|
return ServiceVariantStore(project_root(args) / ".maggie" / "service-variants.json")
|
|
315
342
|
|
|
@@ -399,6 +426,12 @@ def parser() -> argparse.ArgumentParser:
|
|
|
399
426
|
redirect.add_argument("--project", default="."); redirect.add_argument("--project-id", default="local-project"); redirect.add_argument("--from-path", required=True); redirect.add_argument("--to-path", required=True); redirect.add_argument("--status-code", type=int, default=301); redirect.add_argument("--actor", default="cli"); redirect.add_argument("--reason", required=True); redirect.add_argument("--confirm", action="store_true")
|
|
400
427
|
preview = cms_sub.add_parser("preview")
|
|
401
428
|
preview.add_argument("--project", default="."); preview.add_argument("--project-id", default="local-project"); preview.add_argument("--document-id", required=True); preview.add_argument("--secret", required=True); preview.add_argument("--ttl", type=int, default=900); preview.add_argument("--confirm", action="store_true")
|
|
429
|
+
preset_save = cms_sub.add_parser("preset-save", help="save a reusable ordered section arrangement")
|
|
430
|
+
preset_save.add_argument("--project", default="."); preset_save.add_argument("--project-id", default="local-project"); preset_save.add_argument("--preset-id", required=True); preset_save.add_argument("--name", required=True); preset_save.add_argument("--sections-file", required=True); preset_save.add_argument("--description", default=""); preset_save.add_argument("--actor", default="cli"); preset_save.add_argument("--confirm", action="store_true")
|
|
431
|
+
preset_list = cms_sub.add_parser("preset-list", help="list reusable section arrangements")
|
|
432
|
+
preset_list.add_argument("--project", default="."); preset_list.add_argument("--project-id", default="local-project"); preset_list.add_argument("--confirm", action="store_true")
|
|
433
|
+
preset_insert = cms_sub.add_parser("preset-insert", help="expand an arrangement with fresh section IDs")
|
|
434
|
+
preset_insert.add_argument("--project", default="."); preset_insert.add_argument("--project-id", default="local-project"); preset_insert.add_argument("--preset-id", required=True); preset_insert.add_argument("--existing-id", action="append", default=[]); preset_insert.add_argument("--actor", default="cli"); preset_insert.add_argument("--reason", required=True); preset_insert.add_argument("--confirm", action="store_true")
|
|
402
435
|
cms.set_defaults(func=command_cms)
|
|
403
436
|
ui = sub.add_parser("ui", help="validate MaggieDash dashboard chrome")
|
|
404
437
|
ui_sub = ui.add_subparsers(dest="ui_command", required=True)
|
|
@@ -406,6 +439,17 @@ def parser() -> argparse.ArgumentParser:
|
|
|
406
439
|
ui_validate.add_argument("--contract", required=True)
|
|
407
440
|
ui_validate.add_argument("--source", action="append", default=[])
|
|
408
441
|
ui_validate.set_defaults(func=command_ui)
|
|
442
|
+
sections = sub.add_parser("sections", help="validate section registry and stable section identities")
|
|
443
|
+
sections_sub = sections.add_subparsers(dest="sections_command", required=True)
|
|
444
|
+
sections_validate = sections_sub.add_parser("validate")
|
|
445
|
+
sections_validate.add_argument("--registry", required=True); sections_validate.add_argument("--sections-file", required=True)
|
|
446
|
+
sections_validate.set_defaults(func=command_sections)
|
|
447
|
+
sections_prompt = sections_sub.add_parser("prompt")
|
|
448
|
+
sections_prompt.add_argument("--registry", required=True)
|
|
449
|
+
sections_prompt.set_defaults(func=command_sections)
|
|
450
|
+
sections_keys = sections_sub.add_parser("keys")
|
|
451
|
+
sections_keys.add_argument("--registry", required=True); sections_keys.add_argument("--sections-file", required=True); sections_keys.add_argument("--page-id", required=True)
|
|
452
|
+
sections_keys.set_defaults(func=command_sections)
|
|
409
453
|
variant = sub.add_parser("variant", help="manage service variant lifecycle")
|
|
410
454
|
variant_sub = variant.add_subparsers(dest="variant_command", required=True)
|
|
411
455
|
create = variant_sub.add_parser("create"); create.add_argument("--project", default="."); create.add_argument("--service-id", required=True); create.add_argument("--variant-id", required=True); create.add_argument("--variant-type", required=True); create.add_argument("--locale", required=True); create.add_argument("--market", required=True); create.add_argument("--slug", required=True); create.add_argument("--title", required=True); create.add_argument("--facts", required=True); create.add_argument("--source-revision", required=True); create.add_argument("--canonical-variant-id"); create.add_argument("--cluster-link", action="append", default=[]); create.add_argument("--layout-family", default="service-default"); create.add_argument("--confirm", action="store_true")
|
|
@@ -219,6 +219,54 @@ WantedBy=multi-user.target
|
|
|
219
219
|
(output_dir / f"{domain}.conf").write_text(nginx, encoding="utf-8")
|
|
220
220
|
|
|
221
221
|
|
|
222
|
+
def release_runner(plan: dict) -> str:
|
|
223
|
+
"""Render the ordered, reviewable VPS runner for one release."""
|
|
224
|
+
root = plan["release_root"]
|
|
225
|
+
service = plan["service"]
|
|
226
|
+
domain = plan["domain"]
|
|
227
|
+
current = plan["current_release"]
|
|
228
|
+
return f'''#!/usr/bin/env bash
|
|
229
|
+
set -euo pipefail
|
|
230
|
+
|
|
231
|
+
# Migration precedes the symlink flip; later releases carry agent state.
|
|
232
|
+
: "${{RELEASE_ID:?set RELEASE_ID to an immutable release name}}"
|
|
233
|
+
RELEASE_ROOT="{root}"
|
|
234
|
+
CURRENT="{current}"
|
|
235
|
+
RELEASE="$RELEASE_ROOT/releases/$RELEASE_ID"
|
|
236
|
+
PREVIOUS="$(readlink -f "$CURRENT" 2>/dev/null || true)"
|
|
237
|
+
mkdir -p "$RELEASE"
|
|
238
|
+
cd "$RELEASE"
|
|
239
|
+
|
|
240
|
+
npm ci
|
|
241
|
+
npm run typecheck
|
|
242
|
+
npm run migrate
|
|
243
|
+
npm run build
|
|
244
|
+
|
|
245
|
+
if [ -d "$CURRENT/.agents" ]; then cp -a "$CURRENT/.agents" "$RELEASE/.agents"; fi
|
|
246
|
+
if [ -d "$CURRENT/.claude" ]; then cp -a "$CURRENT/.claude" "$RELEASE/.claude"; fi
|
|
247
|
+
|
|
248
|
+
ln -sfn "$RELEASE" "$CURRENT"
|
|
249
|
+
systemctl restart "{service}"
|
|
250
|
+
systemctl is-active --quiet "{service}"
|
|
251
|
+
curl -fsS "https://{domain}/robots.txt" >/dev/null
|
|
252
|
+
curl -fsS "https://{domain}/sitemap.xml" >/dev/null
|
|
253
|
+
|
|
254
|
+
# Keep current plus one rollback candidate, and prune only after health checks.
|
|
255
|
+
mapfile -t RELEASES < <(find "$RELEASE_ROOT/releases" -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\\n' | sort -nr | cut -d' ' -f2-)
|
|
256
|
+
for candidate in "${{RELEASES[@]:2}}"; do
|
|
257
|
+
[ "$candidate" = "$PREVIOUS" ] && continue
|
|
258
|
+
[ "$candidate" = "$RELEASE" ] && continue
|
|
259
|
+
rm -rf -- "$candidate"
|
|
260
|
+
done
|
|
261
|
+
'''
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def write_release_runner(path: Path, plan: dict) -> None:
|
|
265
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
266
|
+
path.write_text(release_runner(plan), encoding="utf-8")
|
|
267
|
+
path.chmod(0o750)
|
|
268
|
+
|
|
269
|
+
|
|
222
270
|
def main() -> int:
|
|
223
271
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
224
272
|
parser.add_argument("project", nargs="?", default=".")
|
|
@@ -231,6 +279,7 @@ def main() -> int:
|
|
|
231
279
|
parser.add_argument("--release-root", default="/var/www/maggie-site", help="immutable release root")
|
|
232
280
|
parser.add_argument("--node-port", type=int, default=4321)
|
|
233
281
|
parser.add_argument("--plan-dir", help="directory for generated VPS artifacts")
|
|
282
|
+
parser.add_argument("--runner-output", help="write a reviewable ordered VPS release runner")
|
|
234
283
|
parser.add_argument("--retention-plan", action="store_true", help="create a read-only release prune candidate plan")
|
|
235
284
|
parser.add_argument("--current-link", help="current symlink for --retention-plan")
|
|
236
285
|
parser.add_argument("--keep-releases", type=int, default=2)
|
|
@@ -249,6 +298,8 @@ def main() -> int:
|
|
|
249
298
|
plan = vps_plan(args.domain, args.service, args.release_root, args.node_port)
|
|
250
299
|
if args.plan_dir:
|
|
251
300
|
write_vps_plan(Path(args.plan_dir).resolve(), plan)
|
|
301
|
+
if args.runner_output:
|
|
302
|
+
write_release_runner(Path(args.runner_output).resolve(), plan)
|
|
252
303
|
print(json.dumps(plan, indent=2, ensure_ascii=False))
|
|
253
304
|
return 0
|
|
254
305
|
if args.retention_plan:
|
|
@@ -12,6 +12,8 @@ from pathlib import Path
|
|
|
12
12
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
|
|
13
13
|
from dependency_lock import audit as audit_lockfiles
|
|
14
14
|
from seed_evidence import validate_manifest
|
|
15
|
+
from agent_runtime import preflight as agent_preflight
|
|
16
|
+
from restart_gate import validate as validate_restart_gate
|
|
15
17
|
|
|
16
18
|
|
|
17
19
|
REQUIRED_ROUTES = (
|
|
@@ -149,6 +151,20 @@ def command_seed_manifest(args: argparse.Namespace) -> int:
|
|
|
149
151
|
return 0 if result["passed"] else 1
|
|
150
152
|
|
|
151
153
|
|
|
154
|
+
def command_agent_preflight(args: argparse.Namespace) -> int:
|
|
155
|
+
result = agent_preflight(release_dir=args.release_dir)
|
|
156
|
+
if args.output:
|
|
157
|
+
write_json(Path(args.output).resolve(), result)
|
|
158
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
159
|
+
return 0
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def command_restart_gate(args: argparse.Namespace) -> int:
|
|
163
|
+
result = validate_restart_gate(read_json(Path(args.manifest).resolve()))
|
|
164
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
165
|
+
return 0 if result["passed"] else 1
|
|
166
|
+
|
|
167
|
+
|
|
152
168
|
def main() -> int:
|
|
153
169
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
154
170
|
parser.add_argument("--project", default=".")
|
|
@@ -160,6 +176,13 @@ def main() -> int:
|
|
|
160
176
|
seed = sub.add_parser("seed-manifest", help="validate explicit sanitized development fixtures")
|
|
161
177
|
seed.add_argument("--manifest", required=True)
|
|
162
178
|
seed.set_defaults(func=command_seed_manifest)
|
|
179
|
+
agent = sub.add_parser("agent-preflight", help="report agent runtime and deployment gotchas")
|
|
180
|
+
agent.add_argument("--output")
|
|
181
|
+
agent.add_argument("--release-dir")
|
|
182
|
+
agent.set_defaults(func=command_agent_preflight)
|
|
183
|
+
restart = sub.add_parser("restart-gate", help="validate restart-after-out-of-band-write evidence")
|
|
184
|
+
restart.add_argument("--manifest", required=True)
|
|
185
|
+
restart.set_defaults(func=command_restart_gate)
|
|
163
186
|
record = sub.add_parser("record", help="record an explicitly authorised operation")
|
|
164
187
|
record.add_argument("operation", choices=("sync", "sitemap-match", "rewrite-queue", "rewrite-approve", "publish", "migration"))
|
|
165
188
|
record.add_argument("--dry-run", action="store_true")
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Validate changed route surfaces and locales from explicit evidence."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
|
|
12
|
+
from surface_coverage import compare # noqa: E402
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main() -> int:
|
|
16
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
17
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
18
|
+
coverage = sub.add_parser("coverage")
|
|
19
|
+
coverage.add_argument("--contract", type=Path, required=True)
|
|
20
|
+
coverage.add_argument("--evidence", type=Path, required=True)
|
|
21
|
+
args = parser.parse_args()
|
|
22
|
+
if args.command == "coverage":
|
|
23
|
+
contract = json.loads(args.contract.resolve().read_text(encoding="utf-8"))
|
|
24
|
+
evidence = json.loads(args.evidence.resolve().read_text(encoding="utf-8"))
|
|
25
|
+
observations = evidence.get("observations") if isinstance(evidence, dict) else evidence
|
|
26
|
+
result = compare(contract, observations)
|
|
27
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
28
|
+
return 0 if result["passed"] else 1
|
|
29
|
+
return 2
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
if __name__ == "__main__":
|
|
33
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Operational checks for running agent CLIs from a deployed workspace."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
ANSI = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def strip_ansi(value: str) -> str:
|
|
14
|
+
return ANSI.sub("", value)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def preflight(environment: dict[str, str] | None = None, release_dir: str | None = None) -> dict:
|
|
18
|
+
env = environment or dict(os.environ)
|
|
19
|
+
warnings = [
|
|
20
|
+
"strip ANSI terminal escapes before parsing URLs, codes, or stored transcripts",
|
|
21
|
+
"use device authentication on a headless host and confirm the account prerequisite before launch",
|
|
22
|
+
"carry agent installs and conversation state outside immutable release directories",
|
|
23
|
+
"put the resume subcommand before agent flags so the CLI cannot treat it as a new prompt",
|
|
24
|
+
]
|
|
25
|
+
risks = []
|
|
26
|
+
if env.get("NO_COLOR") != "1" or env.get("TERM") not in {"dumb", ""}:
|
|
27
|
+
risks.append("terminal color may be enabled; parser must still strip ANSI")
|
|
28
|
+
if release_dir and Path(release_dir).name in {"current", "releases"}:
|
|
29
|
+
risks.append("agent state must not be stored only inside an immutable release")
|
|
30
|
+
return {
|
|
31
|
+
"schemaVersion": "maggie-agent-runtime.v1",
|
|
32
|
+
"passed": True,
|
|
33
|
+
"warnings": warnings,
|
|
34
|
+
"risks": risks,
|
|
35
|
+
"auth": "device",
|
|
36
|
+
"resume": {"codex": ["resume", "--last"], "claude": ["--continue"]},
|
|
37
|
+
"statePolicy": "outside-release-directory",
|
|
38
|
+
}
|
|
@@ -13,6 +13,8 @@ from datetime import datetime, timedelta, timezone
|
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
from typing import Any
|
|
15
15
|
|
|
16
|
+
from maggie_sections import ensure_section_ids
|
|
17
|
+
|
|
16
18
|
|
|
17
19
|
STATUSES = ("draft", "review", "approved", "scheduled", "published", "archived", "trashed")
|
|
18
20
|
TRANSITIONS = {
|
|
@@ -83,6 +85,12 @@ class MaggieDashStore:
|
|
|
83
85
|
actor_id TEXT NOT NULL, reason TEXT NOT NULL, correlation_id TEXT NOT NULL,
|
|
84
86
|
created_at TEXT NOT NULL
|
|
85
87
|
);
|
|
88
|
+
CREATE TABLE IF NOT EXISTS maggiedash_section_presets (
|
|
89
|
+
id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES maggiedash_projects(id),
|
|
90
|
+
name TEXT NOT NULL, description TEXT, body_json TEXT NOT NULL,
|
|
91
|
+
created_by TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
|
92
|
+
UNIQUE(project_id, name)
|
|
93
|
+
);
|
|
86
94
|
"""
|
|
87
95
|
)
|
|
88
96
|
self._ensure_column("maggiedash_documents", "trashed_at", "TEXT")
|
|
@@ -268,6 +276,49 @@ class MaggieDashStore:
|
|
|
268
276
|
self.connection.commit()
|
|
269
277
|
return result
|
|
270
278
|
|
|
279
|
+
def save_section_preset(self, project_id: str, preset_id: str, name: str, sections: list[dict[str, Any]], actor_id: str, description: str = "") -> dict[str, Any]:
|
|
280
|
+
"""Save an ordered arrangement without leaking page-specific IDs."""
|
|
281
|
+
name = name.strip()
|
|
282
|
+
if not name:
|
|
283
|
+
raise ValueError("preset name is required")
|
|
284
|
+
if not isinstance(sections, list) or not sections or not all(isinstance(section, dict) and section.get("type") for section in sections):
|
|
285
|
+
raise ValueError("preset sections must be a non-empty typed list")
|
|
286
|
+
body = []
|
|
287
|
+
for section in sections:
|
|
288
|
+
copy = json.loads(json.dumps(section, ensure_ascii=False))
|
|
289
|
+
copy.pop("id", None)
|
|
290
|
+
body.append(copy)
|
|
291
|
+
timestamp = now()
|
|
292
|
+
self.connection.execute(
|
|
293
|
+
"INSERT INTO maggiedash_section_presets (id,project_id,name,description,body_json,created_by,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?) "
|
|
294
|
+
"ON CONFLICT(id) DO UPDATE SET name=excluded.name, description=excluded.description, body_json=excluded.body_json, updated_at=excluded.updated_at",
|
|
295
|
+
(preset_id, project_id, name, description.strip(), json.dumps(body, ensure_ascii=False), actor_id, timestamp, timestamp),
|
|
296
|
+
)
|
|
297
|
+
self._audit(project_id, "section-preset.saved", "section_preset", preset_id, actor_id, "ordered arrangement saved")
|
|
298
|
+
self.connection.commit()
|
|
299
|
+
return self.get_section_preset(project_id, preset_id) or {}
|
|
300
|
+
|
|
301
|
+
def get_section_preset(self, project_id: str, preset_id: str) -> dict[str, Any] | None:
|
|
302
|
+
row = self.connection.execute("SELECT * FROM maggiedash_section_presets WHERE project_id=? AND id=?", (project_id, preset_id)).fetchone()
|
|
303
|
+
if not row:
|
|
304
|
+
return None
|
|
305
|
+
result = dict(row)
|
|
306
|
+
result["body"] = json.loads(result.pop("body_json"))
|
|
307
|
+
return result
|
|
308
|
+
|
|
309
|
+
def list_section_presets(self, project_id: str) -> list[dict[str, Any]]:
|
|
310
|
+
rows = self.connection.execute("SELECT id FROM maggiedash_section_presets WHERE project_id=? ORDER BY updated_at DESC", (project_id,)).fetchall()
|
|
311
|
+
return [self.get_section_preset(project_id, row["id"]) for row in rows]
|
|
312
|
+
|
|
313
|
+
def insert_section_preset(self, project_id: str, preset_id: str, existing_ids: list[str], actor_id: str, reason: str) -> list[dict[str, Any]]:
|
|
314
|
+
preset = self.get_section_preset(project_id, preset_id)
|
|
315
|
+
if not preset:
|
|
316
|
+
raise ValueError("section preset not found")
|
|
317
|
+
result = ensure_section_ids(preset["body"], existing_ids)
|
|
318
|
+
self._audit(project_id, "section-preset.inserted", "section_preset", preset_id, actor_id, reason)
|
|
319
|
+
self.connection.commit()
|
|
320
|
+
return result
|
|
321
|
+
|
|
271
322
|
def add_redirect(self, project_id: str, from_path: str, to_path: str, actor_id: str, reason: str, status_code: int = 301) -> dict[str, Any]:
|
|
272
323
|
if not from_path.startswith("/") or not to_path.startswith("/") or self._normalise_path(from_path) == self._normalise_path(to_path):
|
|
273
324
|
raise ValueError("redirect paths must be distinct absolute paths")
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Provider-neutral section registry, copy limits, and stable section keys."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import uuid
|
|
10
|
+
from typing import Any, Iterable
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
SCHEMA = "maggiedash-section-registry.v1"
|
|
14
|
+
IDENTITY_SCHEMA = "maggiedash-section-identity.v1"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def validate_registry(value: object) -> dict[str, Any]:
|
|
18
|
+
errors: list[str] = []
|
|
19
|
+
if not isinstance(value, dict):
|
|
20
|
+
return {"schemaVersion": SCHEMA, "passed": False, "errors": ["registry must be an object"]}
|
|
21
|
+
if value.get("schemaVersion") != SCHEMA:
|
|
22
|
+
errors.append(f"schemaVersion must be {SCHEMA}")
|
|
23
|
+
sections = value.get("sections")
|
|
24
|
+
if not isinstance(sections, list) or not sections:
|
|
25
|
+
errors.append("sections must be a non-empty list")
|
|
26
|
+
sections = []
|
|
27
|
+
seen: set[str] = set()
|
|
28
|
+
for index, section in enumerate(sections):
|
|
29
|
+
at = f"sections[{index}]"
|
|
30
|
+
if not isinstance(section, dict):
|
|
31
|
+
errors.append(f"{at} must be an object")
|
|
32
|
+
continue
|
|
33
|
+
section_type = str(section.get("type") or "")
|
|
34
|
+
if not re.fullmatch(r"[a-z][a-z0-9-]*", section_type):
|
|
35
|
+
errors.append(f"{at}.type must be a kebab-case identifier")
|
|
36
|
+
if section_type in seen:
|
|
37
|
+
errors.append(f"duplicate section type: {section_type}")
|
|
38
|
+
seen.add(section_type)
|
|
39
|
+
for field in ("purpose", "usage", "position", "repeatable"):
|
|
40
|
+
if field not in section:
|
|
41
|
+
errors.append(f"{at}.{field} is required")
|
|
42
|
+
if section.get("position") not in {"opening", "body", "closing"}:
|
|
43
|
+
errors.append(f"{at}.position must be opening, body, or closing")
|
|
44
|
+
if not isinstance(section.get("repeatable"), bool):
|
|
45
|
+
errors.append(f"{at}.repeatable must be boolean")
|
|
46
|
+
fields = section.get("fields")
|
|
47
|
+
if not isinstance(fields, list) or not fields:
|
|
48
|
+
errors.append(f"{at}.fields must be a non-empty list")
|
|
49
|
+
continue
|
|
50
|
+
field_names: set[str] = set()
|
|
51
|
+
for field_index, field in enumerate(fields):
|
|
52
|
+
field_at = f"{at}.fields[{field_index}]"
|
|
53
|
+
if not isinstance(field, dict) or not field.get("name"):
|
|
54
|
+
errors.append(f"{field_at} needs a name")
|
|
55
|
+
continue
|
|
56
|
+
name = str(field["name"])
|
|
57
|
+
if name in field_names:
|
|
58
|
+
errors.append(f"duplicate field: {section_type}.{name}")
|
|
59
|
+
field_names.add(name)
|
|
60
|
+
if not isinstance(field.get("limit"), int) or field["limit"] < 0:
|
|
61
|
+
errors.append(f"{field_at}.limit must be a non-negative integer")
|
|
62
|
+
if not str(field.get("usage") or "").strip():
|
|
63
|
+
errors.append(f"{field_at}.usage is required")
|
|
64
|
+
repeats = field.get("repeats")
|
|
65
|
+
if repeats is not None and (not isinstance(repeats, dict) or not isinstance(repeats.get("min"), int) or not isinstance(repeats.get("max"), int) or repeats["min"] < 0 or repeats["min"] > repeats["max"]):
|
|
66
|
+
errors.append(f"{field_at}.repeats must declare valid min/max")
|
|
67
|
+
return {"schemaVersion": SCHEMA, "passed": not errors, "errors": errors, "sectionTypes": sorted(seen)}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def catalogue(registry: dict[str, Any]) -> str:
|
|
71
|
+
"""Render a compact prompt catalogue without duplicating field guidance."""
|
|
72
|
+
lines: list[str] = []
|
|
73
|
+
for section in registry.get("sections", []):
|
|
74
|
+
lines.append(f"{section['type']} — {section['purpose']} | when: {section['usage']} | place: {section['position']} | {'repeatable' if section['repeatable'] else 'once'}")
|
|
75
|
+
for field in section.get("fields", []):
|
|
76
|
+
repeat = field.get("repeats")
|
|
77
|
+
suffix = f" [{repeat['min']}-{repeat['max']} entries]" if isinstance(repeat, dict) else ""
|
|
78
|
+
lines.append(f" - {field['name']} ≤{field['limit']} chars{suffix}: {field['usage']}")
|
|
79
|
+
return "\n".join(lines)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def copy_notes(registry: dict[str, Any], sections: object) -> dict[str, Any]:
|
|
83
|
+
"""Return advisory copy/shape notes; over-limit copy is not rejected."""
|
|
84
|
+
errors: list[str] = []
|
|
85
|
+
notes: list[dict[str, Any]] = []
|
|
86
|
+
if not isinstance(sections, list):
|
|
87
|
+
return {"passed": False, "errors": ["sections must be a list"], "notes": []}
|
|
88
|
+
specs = {str(item.get("type")): item for item in registry.get("sections", []) if isinstance(item, dict)}
|
|
89
|
+
for index, section in enumerate(sections):
|
|
90
|
+
if not isinstance(section, dict):
|
|
91
|
+
errors.append(f"sections[{index}] must be an object")
|
|
92
|
+
continue
|
|
93
|
+
section_type = str(section.get("type") or "")
|
|
94
|
+
spec = specs.get(section_type)
|
|
95
|
+
if not spec:
|
|
96
|
+
errors.append(f"unknown section type: {section_type}")
|
|
97
|
+
continue
|
|
98
|
+
for field in spec.get("fields", []):
|
|
99
|
+
name = str(field.get("name"))
|
|
100
|
+
value = section.get(name)
|
|
101
|
+
limit = int(field.get("limit", 0))
|
|
102
|
+
if isinstance(value, str) and limit and len(value) > limit:
|
|
103
|
+
notes.append({"at": f"sections[{index}].{name}", "message": f"{len(value)} characters; layout limit is about {limit}"})
|
|
104
|
+
repeats = field.get("repeats")
|
|
105
|
+
if isinstance(repeats, dict) and isinstance(value, list) and not repeats["min"] <= len(value) <= repeats["max"]:
|
|
106
|
+
notes.append({"at": f"sections[{index}].{name}", "message": f"{len(value)} entries; layout expects {repeats['min']}-{repeats['max']}"})
|
|
107
|
+
return {"passed": not errors, "errors": errors, "notes": notes}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def new_section_id(existing: Iterable[str] = ()) -> str:
|
|
111
|
+
taken = set(existing)
|
|
112
|
+
while True:
|
|
113
|
+
candidate = uuid.uuid4().hex[:8]
|
|
114
|
+
if candidate not in taken:
|
|
115
|
+
return candidate
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def ensure_section_ids(sections: list[dict[str, Any]], existing: Iterable[str] = ()) -> list[dict[str, Any]]:
|
|
119
|
+
result = copy.deepcopy(sections)
|
|
120
|
+
existing_ids = set(existing)
|
|
121
|
+
taken = set(existing_ids)
|
|
122
|
+
for section in result:
|
|
123
|
+
current = str(section.get("id") or "")
|
|
124
|
+
if not current or current in existing_ids or current in taken:
|
|
125
|
+
section["id"] = new_section_id(taken)
|
|
126
|
+
taken.add(str(section["id"]))
|
|
127
|
+
return result
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def section_key(section: object, index: int) -> str:
|
|
131
|
+
if isinstance(section, dict) and isinstance(section.get("id"), str) and section["id"]:
|
|
132
|
+
return section["id"]
|
|
133
|
+
return str(index)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def translation_key(page_id: str, section: object, index: int, field: str) -> str:
|
|
137
|
+
return f"page.{page_id}.sections.{section_key(section, index)}.{field}"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def section_id_migration(page_id: str, sections: list[dict[str, Any]]) -> dict[str, Any]:
|
|
141
|
+
identified = ensure_section_ids(sections)
|
|
142
|
+
mappings = [{"from": f"page.{page_id}.sections.{index}.", "to": f"page.{page_id}.sections.{section['id']}."} for index, section in enumerate(identified)]
|
|
143
|
+
return {"schemaVersion": IDENTITY_SCHEMA, "pageId": page_id, "sections": identified, "renameOrder": sorted(mappings, key=lambda item: len(item["from"]), reverse=True), "sourceChecksum": hashlib.sha256(json.dumps(sections, sort_keys=True, ensure_ascii=False, separators=(",", ":")).encode()).hexdigest()}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Evidence gate for writes performed outside the running application."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def validate(value: object) -> dict[str, Any]:
|
|
9
|
+
errors: list[str] = []
|
|
10
|
+
if not isinstance(value, dict):
|
|
11
|
+
return {"schemaVersion": "maggie-restart-gate.v1", "passed": False, "errors": ["restart gate must be an object"]}
|
|
12
|
+
if value.get("schemaVersion") != "maggie-restart-gate.v1":
|
|
13
|
+
errors.append("unsupported restart gate schema")
|
|
14
|
+
if value.get("outOfBandWrite") is not True:
|
|
15
|
+
errors.append("outOfBandWrite must be true for this gate")
|
|
16
|
+
if value.get("restartRequired") is not True:
|
|
17
|
+
errors.append("restartRequired must be true after an out-of-band write")
|
|
18
|
+
if not str(value.get("restartCommand") or "").strip():
|
|
19
|
+
errors.append("restartCommand is required")
|
|
20
|
+
if value.get("verificationAfterRestart") is not True:
|
|
21
|
+
errors.append("verificationAfterRestart must be true")
|
|
22
|
+
return {"schemaVersion": "maggie-restart-gate.v1", "passed": not errors, "errors": errors, "order": ["write", "restart", "verify"]}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Coverage checks that follow the surface and locale changed by a release."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
SCHEMA = "maggie-surface-coverage.v1"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def validate_contract(contract: object) -> dict[str, Any]:
|
|
12
|
+
errors: list[str] = []
|
|
13
|
+
if not isinstance(contract, dict):
|
|
14
|
+
return {"schemaVersion": SCHEMA, "passed": False, "errors": ["coverage contract must be an object"]}
|
|
15
|
+
if contract.get("schemaVersion") != SCHEMA:
|
|
16
|
+
errors.append(f"schemaVersion must be {SCHEMA}")
|
|
17
|
+
surfaces = contract.get("surfaces")
|
|
18
|
+
if not isinstance(surfaces, list) or not surfaces:
|
|
19
|
+
errors.append("surfaces must be a non-empty list")
|
|
20
|
+
surfaces = []
|
|
21
|
+
seen: set[tuple[str, str]] = set()
|
|
22
|
+
for index, item in enumerate(surfaces):
|
|
23
|
+
if not isinstance(item, dict) or not item.get("name") or not isinstance(item.get("routes"), list) or not item["routes"] or not isinstance(item.get("locales"), list) or not item["locales"]:
|
|
24
|
+
errors.append(f"surfaces[{index}] needs name, routes, and locales")
|
|
25
|
+
continue
|
|
26
|
+
for locale in item["locales"]:
|
|
27
|
+
key = (str(item["name"]), str(locale))
|
|
28
|
+
if key in seen:
|
|
29
|
+
errors.append(f"duplicate surface/locale: {key[0]}/{key[1]}")
|
|
30
|
+
seen.add(key)
|
|
31
|
+
return {"schemaVersion": SCHEMA, "passed": not errors, "errors": errors, "expectedPairs": sorted(f"{surface}/{locale}" for surface, locale in seen)}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def compare(contract: dict[str, Any], observations: object) -> dict[str, Any]:
|
|
35
|
+
checked = validate_contract(contract)
|
|
36
|
+
if not checked["passed"]:
|
|
37
|
+
return {**checked, "observedPairs": [], "missing": [], "failed": []}
|
|
38
|
+
expected = {(str(item["name"]), str(locale)) for item in contract["surfaces"] for locale in item["locales"]}
|
|
39
|
+
observed: dict[tuple[str, str], dict[str, Any]] = {}
|
|
40
|
+
for item in observations if isinstance(observations, list) else []:
|
|
41
|
+
if not isinstance(item, dict):
|
|
42
|
+
continue
|
|
43
|
+
key = (str(item.get("surface") or ""), str(item.get("locale") or ""))
|
|
44
|
+
if key in expected:
|
|
45
|
+
observed[key] = item
|
|
46
|
+
missing = sorted(f"{surface}/{locale}" for surface, locale in expected - observed.keys())
|
|
47
|
+
failed = sorted(f"{surface}/{locale}" for (surface, locale), item in observed.items() if item.get("passed") is not True or int(item.get("status", 200)) >= 400)
|
|
48
|
+
return {"schemaVersion": SCHEMA, "passed": not missing and not failed, "expectedPairs": sorted(f"{surface}/{locale}" for surface, locale in expected), "observedPairs": sorted(f"{surface}/{locale}" for surface, locale in observed), "missing": missing, "failed": failed}
|