@webpresso/agent-kit 3.3.2 → 3.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/catalog/AGENTS.md.tpl +3 -5
  2. package/catalog/agent/skills/verify/SKILL.md +12 -12
  3. package/catalog/compose/admin-blocks-registry.json +659 -0
  4. package/dist/esm/blueprint/core/parser.js +2 -4
  5. package/dist/esm/blueprint/core/validation/criteria.js +2 -1
  6. package/dist/esm/blueprint/lifecycle/review-provenance.js +85 -30
  7. package/dist/esm/build/cli-mcp-parity.js +3 -2
  8. package/dist/esm/build/package-manifest.js +7 -2
  9. package/dist/esm/cli/commands/audit-core.js +9 -1
  10. package/dist/esm/cli/commands/audit.js +16 -5
  11. package/dist/esm/cli/commands/init/mcp-spec.d.ts +18 -3
  12. package/dist/esm/cli/commands/init/mcp-spec.js +54 -6
  13. package/dist/esm/cli/commands/init/scaffold-agents-md.js +5 -7
  14. package/dist/esm/cli/commands/package-manager.js +8 -2
  15. package/dist/esm/compose/compose.d.ts +38 -0
  16. package/dist/esm/compose/compose.js +143 -0
  17. package/dist/esm/compose/escape-html.d.ts +5 -0
  18. package/dist/esm/compose/escape-html.js +12 -0
  19. package/dist/esm/compose/ops-report-render.d.ts +5 -0
  20. package/dist/esm/compose/ops-report-render.js +71 -0
  21. package/dist/esm/compose/ops-report-schema.d.ts +42 -0
  22. package/dist/esm/compose/ops-report-schema.js +46 -0
  23. package/dist/esm/compose/preview.d.ts +14 -0
  24. package/dist/esm/compose/preview.js +32 -0
  25. package/dist/esm/compose/registry-resolve.d.ts +47 -0
  26. package/dist/esm/compose/registry-resolve.js +133 -0
  27. package/dist/esm/git/changed-files.d.ts +20 -0
  28. package/dist/esm/git/changed-files.js +63 -0
  29. package/dist/esm/hooks/stop/qa-changed-files.js +6 -1
  30. package/dist/esm/mcp/blueprint/handlers/validate.js +6 -0
  31. package/dist/esm/mcp/tools/_names.d.ts +1 -1
  32. package/dist/esm/mcp/tools/_names.js +4 -0
  33. package/dist/esm/mcp/tools/_registry.js +8 -0
  34. package/dist/esm/mcp/tools/release-progress.d.ts +9 -0
  35. package/dist/esm/mcp/tools/release-progress.js +110 -0
  36. package/dist/esm/mcp/tools/wp-ui-blocks-list.d.ts +3 -0
  37. package/dist/esm/mcp/tools/wp-ui-blocks-list.js +43 -0
  38. package/dist/esm/mcp/tools/wp-ui-compose.d.ts +3 -0
  39. package/dist/esm/mcp/tools/wp-ui-compose.js +62 -0
  40. package/dist/esm/mcp/tools/wp-ui-preview.d.ts +3 -0
  41. package/dist/esm/mcp/tools/wp-ui-preview.js +52 -0
  42. package/dist/esm/package.json +2 -0
  43. package/package.json +23 -12
@@ -139,6 +139,83 @@ function workingContentMatchesRef(repoRoot, file, purpose, ref) {
139
139
  return false;
140
140
  }
141
141
  }
142
+ /**
143
+ * Candidate delivery subjects for the working path.
144
+ *
145
+ * Two sources, in this order:
146
+ *
147
+ * 1. **The live tree at HEAD.** This mirrors what {@link readReviewAuthorityAtRef}
148
+ * already does for delivery on the ref path: it builds the subject from the
149
+ * ref under audit and matches events by digest, never resolving the commit an
150
+ * event recorded. Doing the same here removes the working path's outlier
151
+ * behaviour.
152
+ * 2. **Reconstruction at each event's `reviewedCommit`**, for events the live
153
+ * subject does not already cover.
154
+ *
155
+ * Why the live tree has to come first: `reviewedCommit` is a *locator*, not the
156
+ * binding. The binding is `subjectDigest`, which content-addresses the reviewed
157
+ * tree. A squash merge destroys the branch commit while preserving its tree, so
158
+ * an approval recorded at the branch tip kept a digest that still describes the
159
+ * post-merge tree exactly — yet resolving its now-orphaned SHA threw, the event
160
+ * was marked compromised, and a genuinely valid approval was discarded. The
161
+ * standing workaround was to hand-append a rebind event after every squash.
162
+ *
163
+ * Matching on the digest first is not a loosening: an event is accepted only
164
+ * when the tree it recorded is byte-for-byte the tree we have. Content that
165
+ * differs still fails, and an event whose commit is gone *and* whose digest
166
+ * matches nothing reachable is still marked unresolvable — fail-closed.
167
+ */
168
+ function collectWorkingDeliverySubjects(input) {
169
+ const { repoRoot, slug, blueprintPath, events, unresolvableEventIds, issues } = input;
170
+ const candidates = new Map();
171
+ // Nothing to resolve against: skip before paying for a whole-tree read. This
172
+ // path also runs for plan-only ledgers, where the old code cost nothing.
173
+ if (!events.some((event) => event.purpose === "delivery"))
174
+ return [];
175
+ let liveDigest = null;
176
+ try {
177
+ const live = createDeliverySubjectAtRef(repoRoot, "HEAD", slug, blueprintPath);
178
+ liveDigest = live.digest;
179
+ candidates.set(live.digest, { scheme: live.scheme, digest: live.digest });
180
+ }
181
+ catch (error) {
182
+ // No HEAD (fresh repo) or no blueprint document at HEAD (first commit of a
183
+ // new blueprint). Not fatal: fall through to per-event reconstruction.
184
+ issues?.push(`Working delivery subject unavailable at HEAD for ${slug}: ${error instanceof Error ? error.message : String(error)}`);
185
+ }
186
+ for (const event of events) {
187
+ if (event.purpose !== "delivery")
188
+ continue;
189
+ // Already covered by the live tree — the reviewed content is exactly what
190
+ // we have, so the recorded commit does not need to exist any more.
191
+ if (liveDigest !== null && event.subjectDigest === liveDigest)
192
+ continue;
193
+ // Scope subject-construction failure to THIS event. A rebase rewrites
194
+ // commit SHAs, so a previously-recorded `reviewedCommit` can become
195
+ // unresolvable; letting that throw would escape to the outer catch and
196
+ // discard every approval, including valid ones for commits that still
197
+ // resolve. Mirror the ref-path's markCompromisedArtifactEvents: record the
198
+ // issue, mark the event compromised, and drop only it.
199
+ let subject;
200
+ try {
201
+ subject = createDeliverySubjectAtRef(repoRoot, event.reviewedCommit, slug, blueprintPath);
202
+ }
203
+ catch (error) {
204
+ issues?.push(`Working delivery review subject unavailable for ${event.id} at ${event.reviewedCommit}: ${error instanceof Error ? error.message : String(error)}`);
205
+ // Compromised (not merely skipped) so the event can never grant approval
206
+ // while still participating in reviewer supersession — see
207
+ // evaluateReviewEvents.
208
+ unresolvableEventIds.add(event.id);
209
+ continue;
210
+ }
211
+ if (subject.digest !== event.subjectDigest) {
212
+ issues?.push(`Working delivery review subject mismatch for ${event.id}: expected ${subject.digest}, found ${event.subjectDigest}.`);
213
+ continue;
214
+ }
215
+ candidates.set(subject.digest, { scheme: subject.scheme, digest: subject.digest });
216
+ }
217
+ return [...candidates.values()];
218
+ }
142
219
  // Working-ledger acceptance: pre-commit validates a blueprint whose new review
143
220
  // events are staged alongside it and therefore not yet at any git ref. An
144
221
  // event still counts only when its subjectDigest matches the digest of the
@@ -167,36 +244,14 @@ function collectWorkingV2ReviewProvenance(file, purpose, issues) {
167
244
  createLegacyPlanSubjectDigestForContent(slug, readFileSync(file)),
168
245
  ].map((digest) => [digest, { scheme: "wp-blueprint-v1", digest }])).values(),
169
246
  ]
170
- : [
171
- ...new Map(working.events
172
- .filter((event) => event.purpose === "delivery")
173
- .flatMap((event) => {
174
- // Scope subject-construction failure to THIS event. A rebase
175
- // rewrites commit SHAs, so a previously-recorded
176
- // `reviewedCommit` can become unresolvable; letting that throw
177
- // would escape to the outer catch and discard every approval,
178
- // including valid ones for commits that still resolve. Mirror
179
- // the ref-path's markCompromisedArtifactEvents: record the
180
- // issue, mark the event compromised, and drop only it.
181
- let subject;
182
- try {
183
- subject = createDeliverySubjectAtRef(repoRoot, event.reviewedCommit, slug, normalizePath(path.relative(repoRoot, file)));
184
- }
185
- catch (error) {
186
- issues?.push(`Working delivery review subject unavailable for ${event.id} at ${event.reviewedCommit}: ${error instanceof Error ? error.message : String(error)}`);
187
- // Compromised (not merely skipped) so the event can never
188
- // grant approval while still participating in reviewer
189
- // supersession — see evaluateReviewEvents.
190
- unresolvableEventIds.add(event.id);
191
- return [];
192
- }
193
- if (subject.digest !== event.subjectDigest) {
194
- issues?.push(`Working delivery review subject mismatch for ${event.id}: expected ${subject.digest}, found ${event.subjectDigest}.`);
195
- return [];
196
- }
197
- return [[subject.digest, subject]];
198
- })).values(),
199
- ];
247
+ : collectWorkingDeliverySubjects({
248
+ repoRoot,
249
+ slug,
250
+ blueprintPath: normalizePath(path.relative(repoRoot, file)),
251
+ events: working.events,
252
+ unresolvableEventIds,
253
+ issues,
254
+ });
200
255
  return subjects
201
256
  .flatMap((subject) => evaluateReviewEvents(working.events, {
202
257
  purpose,
@@ -52,8 +52,9 @@ export const CLI_MCP_PARITY_CLASSIFICATION = {
52
52
  },
53
53
  "release-progress": {
54
54
  command: "release-progress",
55
- classification: "follow-up-candidate",
56
- reason: "operator/agent Release workflow phase reporter (detect/native-matrix/publish); CLI-only, uses gh API",
55
+ classification: "existing-mcp",
56
+ tools: ["wp_release_progress"],
57
+ reason: "Release workflow phase reporter (detect/native-matrix/publish) is available as wp_release_progress MCP and CLI",
57
58
  },
58
59
  cleanup: {
59
60
  command: "cleanup",
@@ -1,8 +1,8 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { parse as parseYaml } from "yaml";
4
- import { assertBuiltBlueprintMigrationSqlAssets } from "./blueprint-migration-assets.js";
5
- import { assertBuiltSastRuleAssets } from "./sast-rule-assets.js";
4
+ import { assertBuiltBlueprintMigrationSqlAssets, syncBlueprintMigrationSqlAssets, } from "./blueprint-migration-assets.js";
5
+ import { assertBuiltSastRuleAssets, syncSastRuleAssets } from "./sast-rule-assets.js";
6
6
  import { SESSION_MEMORY_NATIVE_TARGETS } from "#session-memory/native-targets.js";
7
7
  import { RUNTIME_TARGETS } from "./runtime-targets.js";
8
8
  const DEPENDENCY_SECTIONS = [
@@ -304,6 +304,11 @@ export function preparePackedManifest(rootDir, options = {}) {
304
304
  // out-of-org consumers cannot resolve.
305
305
  const packedManifest = createPackedManifest(manifest, readWorkspaceCatalogs(workspacePath), options);
306
306
  if (options.assertBlueprintMigrationAssets ?? true) {
307
+ // Sync authoring sources into dist before assert so pack/prepack paths
308
+ // (version dry-run, npm pack) do not require a full `pnpm run build` first.
309
+ // Assert still fails closed on missing source or content drift.
310
+ syncBlueprintMigrationSqlAssets(rootDir);
311
+ syncSastRuleAssets(rootDir);
307
312
  assertBuiltBlueprintMigrationSqlAssets(rootDir);
308
313
  assertBuiltSastRuleAssets(rootDir);
309
314
  }
@@ -79,7 +79,15 @@ export async function runAuditDispatch(auditKind, targets, options, deps) {
79
79
  case "blueprint-pr-coverage": {
80
80
  const { auditBlueprintPrCoverage } = await import("#audit/blueprint-pr-coverage");
81
81
  const root = options.root ?? target ?? deps.root;
82
- const baseRef = options.baseRef ?? options.base;
82
+ const explicitBaseRef = options.baseRef ?? options.base;
83
+ // Same pinned-base hazard as the branch-scoped audits: CI's
84
+ // `github.event.pull_request.base.sha` stops being the fork point once the
85
+ // branch is rebased, and this audit diffs `<baseRef>...HEAD`. Narrow it to
86
+ // the real fork point so coverage is judged on this PR's own files.
87
+ const { resolveBranchDiffBase } = await import("#git/changed-files");
88
+ const baseRef = explicitBaseRef
89
+ ? resolveBranchDiffBase(root, explicitBaseRef)
90
+ : explicitBaseRef;
83
91
  const result = auditBlueprintPrCoverage(root, { baseRef, headRef: options.headRef });
84
92
  return { kind: "repo-result", name: "blueprint-pr-coverage", result };
85
93
  }
@@ -9,7 +9,7 @@ import { STRYKER_COMMAND } from "#audit/run-stryker.js";
9
9
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
10
  import path from "node:path";
11
11
  import { addAffectedOptions, resolveAffectedTargets } from "#git/affected";
12
- import { defaultBranchBaseRef, getBranchRemovedFiles, getStagedRemovedFiles, } from "#git/changed-files";
12
+ import { defaultBranchBaseRef, getBranchRemovedFiles, getStagedRemovedFiles, resolveBranchDiffBase, } from "#git/changed-files";
13
13
  import { runAuditDispatch } from "#cli/commands/audit-core.js";
14
14
  import { createCliLogSink } from "#cli/commands/quality-log-store.js";
15
15
  import { emitCliCommandOutput, runLoggedChildCommand } from "#cli/commands/quality-runner.js";
@@ -284,6 +284,17 @@ export function registerAuditCommand(cli) {
284
284
  let dispatchOptions = options;
285
285
  let auditScope = "full";
286
286
  let changeRemovesFile = false;
287
+ // A caller-supplied `--base` is a PINNED sha (CI passes
288
+ // `github.event.pull_request.base.sha`, which does not advance with the
289
+ // base branch). After a rebase it stops being the fork point, so scoping
290
+ // off it silently absorbs everything merged into the base branch since
291
+ // the PR opened. Narrow it to the real fork point once, and use that one
292
+ // value everywhere the branch scope is derived so the affected file set,
293
+ // the removal probe, and the audit's own base-vs-head range agree.
294
+ const explicitBaseRef = options.baseRef ?? options.base;
295
+ const branchBaseRef = branch && explicitBaseRef
296
+ ? resolveBranchDiffBase(auditRoot, explicitBaseRef)
297
+ : explicitBaseRef;
287
298
  if (affected || branch) {
288
299
  if (kind &&
289
300
  AUDIT_KINDS.includes(kind) &&
@@ -309,7 +320,7 @@ export function registerAuditCommand(cli) {
309
320
  cwd: auditRoot,
310
321
  affected,
311
322
  branch,
312
- baseRef: options.baseRef ?? options.base,
323
+ baseRef: branchBaseRef,
313
324
  explicitTargets: [],
314
325
  policy: "fail-closed",
315
326
  mapChangedFiles: (files) => files,
@@ -360,7 +371,7 @@ export function registerAuditCommand(cli) {
360
371
  changeRemovesFile = affected
361
372
  ? affectedChangeRemovesFile(auditRoot, {
362
373
  branch,
363
- baseRef: options.baseRef ?? options.base,
374
+ baseRef: branchBaseRef,
364
375
  })
365
376
  : false;
366
377
  if (resolution.kind === "empty" && !changeRemovesFile) {
@@ -392,7 +403,7 @@ export function registerAuditCommand(cli) {
392
403
  changedOnly: true,
393
404
  affectedFiles: [],
394
405
  root: options.root ?? resolution.cwd,
395
- baseRef: options.baseRef ?? options.base,
406
+ baseRef: branchBaseRef,
396
407
  };
397
408
  }
398
409
  if (resolution.kind === "scoped") {
@@ -404,7 +415,7 @@ export function registerAuditCommand(cli) {
404
415
  changedOnly: true,
405
416
  affectedFiles: [...resolution.targets],
406
417
  root: options.root ?? resolution.cwd,
407
- baseRef: options.baseRef ?? options.base,
418
+ baseRef: branchBaseRef,
408
419
  };
409
420
  }
410
421
  }
@@ -36,13 +36,28 @@ export interface WebpressoInstallProbe {
36
36
  * machine. Probes the locations consumers use to install webpresso, in order
37
37
  * of stability:
38
38
  *
39
- * 1. the stable global `wp` shim available on PATH
40
- * 2. the currently executing `@webpresso/agent-kit` package
39
+ * 1. the stable global `wp` shim available on PATH (including the
40
+ * vite-plus multi-package shim at `~/.vite-plus/bin/wp`)
41
+ * 2. the currently executing `@webpresso/agent-kit` package **only when it
42
+ * is not a vite-plus `agent-kit#<installId>` payload path**
41
43
  * 3. Claude plugin install — `~/.claude/plugins/cache/.../agent-kit/`
42
44
  * 4. bun global — `~/.bun/install/global/node_modules/@webpresso/agent-kit/`
43
- * Returns `null` when none of the candidates contain a usable `bin/wp`.
45
+ *
46
+ * Host MCP configs (Grok/Codex/etc.) must never be pinned to a versioned
47
+ * installId tree: the next `wp update` / `vp update -g` deletes that path and
48
+ * leaves every host with a dead launcher. Returns `null` when none of the
49
+ * candidates yield a durable `wp`.
44
50
  */
45
51
  export declare function findWebpressoMcpEntry(probe?: WebpressoInstallProbe): string | null;
52
+ /**
53
+ * True when `entryPath` is the stable vite-plus multi-package `wp` shim that
54
+ * dispatches to `@webpresso/agent-kit` via `~/.vite-plus/bins/wp.json`.
55
+ *
56
+ * Unlike {@link isWebpressoWpEntry}, this does **not** require realpath to land
57
+ * inside a package `bin/wp` tree — vite-plus's `wp` is a symlink to the `vp`
58
+ * binary, so package-root ownership is proven via the bins metadata instead.
59
+ */
60
+ export declare function isVitePlusManagedWebpressoWp(entryPath: string): boolean;
46
61
  export declare function resolveWebpressoMcpEntry(probe?: WebpressoInstallProbe): {
47
62
  entryPath: string | null;
48
63
  checked: readonly string[];
@@ -12,9 +12,10 @@
12
12
  */
13
13
  import { accessSync, constants, existsSync, readFileSync, realpathSync, statSync } from "node:fs";
14
14
  import { homedir } from "node:os";
15
- import { dirname, isAbsolute, join, relative, resolve } from "node:path";
15
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
16
16
  import { execFileSync } from "node:child_process";
17
17
  import { fileURLToPath } from "node:url";
18
+ import { isVersionedAgentKitPayloadPath } from "#cli/auto-update/self-invocation.js";
18
19
  import { pathCandidates } from "#runtime/command-exists.js";
19
20
  export const WEBPRESSO_MCP_SERVER_NAME = "webpresso";
20
21
  /**
@@ -67,11 +68,17 @@ const WP_LAUNCHER_REQUIRED_RELATIVES = [
67
68
  * machine. Probes the locations consumers use to install webpresso, in order
68
69
  * of stability:
69
70
  *
70
- * 1. the stable global `wp` shim available on PATH
71
- * 2. the currently executing `@webpresso/agent-kit` package
71
+ * 1. the stable global `wp` shim available on PATH (including the
72
+ * vite-plus multi-package shim at `~/.vite-plus/bin/wp`)
73
+ * 2. the currently executing `@webpresso/agent-kit` package **only when it
74
+ * is not a vite-plus `agent-kit#<installId>` payload path**
72
75
  * 3. Claude plugin install — `~/.claude/plugins/cache/.../agent-kit/`
73
76
  * 4. bun global — `~/.bun/install/global/node_modules/@webpresso/agent-kit/`
74
- * Returns `null` when none of the candidates contain a usable `bin/wp`.
77
+ *
78
+ * Host MCP configs (Grok/Codex/etc.) must never be pinned to a versioned
79
+ * installId tree: the next `wp update` / `vp update -g` deletes that path and
80
+ * leaves every host with a dead launcher. Returns `null` when none of the
81
+ * candidates yield a durable `wp`.
75
82
  */
76
83
  export function findWebpressoMcpEntry(probe = {}) {
77
84
  return resolveWebpressoMcpEntry(probe).entryPath;
@@ -79,6 +86,37 @@ export function findWebpressoMcpEntry(probe = {}) {
79
86
  function isUsableWebpressoMcpRoot(root) {
80
87
  return WP_LAUNCHER_REQUIRED_RELATIVES.every((relativePath) => existsSync(join(root, relativePath)));
81
88
  }
89
+ /**
90
+ * True when `entryPath` is the stable vite-plus multi-package `wp` shim that
91
+ * dispatches to `@webpresso/agent-kit` via `~/.vite-plus/bins/wp.json`.
92
+ *
93
+ * Unlike {@link isWebpressoWpEntry}, this does **not** require realpath to land
94
+ * inside a package `bin/wp` tree — vite-plus's `wp` is a symlink to the `vp`
95
+ * binary, so package-root ownership is proven via the bins metadata instead.
96
+ */
97
+ export function isVitePlusManagedWebpressoWp(entryPath) {
98
+ try {
99
+ if (isVersionedAgentKitPayloadPath(entryPath))
100
+ return false;
101
+ const binDir = dirname(entryPath);
102
+ if (basename(binDir) !== "bin")
103
+ return false;
104
+ if (basename(dirname(binDir)) !== ".vite-plus")
105
+ return false;
106
+ const base = basename(entryPath).toLowerCase();
107
+ if (base !== "wp" && base !== "wp.exe")
108
+ return false;
109
+ const metaPath = join(dirname(binDir), "bins", "wp.json");
110
+ const meta = JSON.parse(readFileSync(metaPath, "utf8"));
111
+ return meta.package === "@webpresso/agent-kit";
112
+ }
113
+ catch {
114
+ return false;
115
+ }
116
+ }
117
+ function isDurableMcpEntryPath(entryPath) {
118
+ return !isVersionedAgentKitPayloadPath(entryPath);
119
+ }
82
120
  export function resolveWebpressoMcpEntry(probe = {}) {
83
121
  const checked = [];
84
122
  if (probe.candidates === undefined) {
@@ -88,10 +126,17 @@ export function resolveWebpressoMcpEntry(probe = {}) {
88
126
  if (stableEntry) {
89
127
  checked.push(stableEntry);
90
128
  const packageWp = packageWpFromAdjacentNpmCmdShim(stableEntry);
91
- if (packageWp !== null)
129
+ if (packageWp !== null && isDurableMcpEntryPath(packageWp)) {
92
130
  return { entryPath: packageWp, checked };
131
+ }
93
132
  const verifierAllows = probe.stableEntryIsWebpresso?.(stableEntry) ?? true;
94
- if (verifierAllows && isWebpressoWpEntry(stableEntry)) {
133
+ // Prefer the vite-plus multi-package shim before package-root ownership:
134
+ // it survives installId swaps and is what PATH `wp` resolves to in the
135
+ // default consumer install.
136
+ if (verifierAllows && isVitePlusManagedWebpressoWp(stableEntry)) {
137
+ return { entryPath: stableEntry, checked };
138
+ }
139
+ if (verifierAllows && isWebpressoWpEntry(stableEntry) && isDurableMcpEntryPath(stableEntry)) {
95
140
  return { entryPath: stableEntry, checked };
96
141
  }
97
142
  }
@@ -103,6 +148,9 @@ export function resolveWebpressoMcpEntry(probe = {}) {
103
148
  continue;
104
149
  checked.push(root);
105
150
  const wpBin = join(root, WP_BIN_RELATIVE);
151
+ // Never materialize host MCP configs against a vite-plus installId tree.
152
+ if (!isDurableMcpEntryPath(wpBin))
153
+ continue;
106
154
  if (isTrustedWebpressoPackageRoot(root))
107
155
  return { entryPath: wpBin, checked };
108
156
  }
@@ -30,13 +30,11 @@ const TECH_STACK_RULES = [
30
30
  { dep: /^typescript$/, label: "TypeScript" },
31
31
  ];
32
32
  // Kept under the 8 KB (8192-byte) hard ceiling. The rendered template embeds
33
- // the full `native_tool_names` registry list, so this soft target tracks the
34
- // tool count: adding an MCP tool grows the list by ~15-17 bytes. Bumped
35
- // 8_100 -> 8_180 for the four-tool batch (wp_ci_preflight, wp_session_id,
36
- // wp_session_info, wp_review_run), which renders ~8_160 still 30+ bytes under
37
- // 8_192. The 8_192 ceiling is the real guardrail; the next addition that would
38
- // breach it should trigger a conscious restructure (or exclude the
39
- // auto-generated tool list from the prose budget), not another reflexive bump.
33
+ // the full `native_tool_names` registry list (~15-17 bytes per tool). Soft
34
+ // target 8_180 leaves headroom under 8_192. The ui-compose batch
35
+ // (wp_ui_blocks_list, wp_ui_compose, wp_ui_preview) required trimming catalog
36
+ // AGENTS.md.tpl prose rather than raising the ceiling. Next breach of 8_192
37
+ // should restructure (or stop embedding the full tool list), not bump again.
40
38
  export const AGENTS_MD_MAX_BYTES = 8_180;
41
39
  export function renderRepositoryMap(consumer) {
42
40
  const packages = consumer.workspacePackages;
@@ -184,6 +184,9 @@ function runGlobalUpdateCommand(deps) {
184
184
  else {
185
185
  failed = true;
186
186
  console.error(message);
187
+ // Required step failed — stop so optional host refreshes cannot
188
+ // look like a successful update after a broken agent-kit install.
189
+ break;
187
190
  }
188
191
  }
189
192
  }
@@ -195,6 +198,7 @@ function runGlobalUpdateCommand(deps) {
195
198
  else {
196
199
  failed = true;
197
200
  console.error(message);
201
+ break;
198
202
  }
199
203
  }
200
204
  }
@@ -313,8 +317,10 @@ function runAgentKitSelfUpdateStep(deps) {
313
317
  return spawnLike(status, new Error("another agent-kit installer is active; re-run `wp update` once it finishes"));
314
318
  case GUARD_EXIT_OWNERSHIP_LOST:
315
319
  return spawnLike(status, new Error("install lease ownership was lost mid-update; the takeover installer verifies the store — re-run `wp update` to confirm"));
316
- default:
317
- return spawnLike(status === 0 ? 1 : status, new Error(`self-install-guard exited ${status}`));
320
+ default: {
321
+ const code = status === 0 ? 1 : status;
322
+ return spawnLike(code, new Error(`agent-kit self-install failed (self-install-guard exit ${code}); re-run \`wp update\` or \`vp install -g @webpresso/agent-kit\``));
323
+ }
318
324
  }
319
325
  }
320
326
  function refreshCodexPlugin(deps) {
@@ -0,0 +1,38 @@
1
+ import { z } from "zod";
2
+ import { type ResolvedBlockRegistry } from "./registry-resolve.js";
3
+ export declare const composeInputSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
4
+ profile: z.ZodLiteral<"admin-crud">;
5
+ kind: z.ZodEnum<{
6
+ detail: "detail";
7
+ form: "form";
8
+ list: "list";
9
+ }>;
10
+ spec: z.ZodRecord<z.ZodString, z.ZodUnknown>;
11
+ }, z.core.$strict>, z.ZodObject<{
12
+ profile: z.ZodLiteral<"ops-report">;
13
+ spec: z.ZodUnknown;
14
+ }, z.core.$strict>], "profile">;
15
+ export type ComposeInput = z.infer<typeof composeInputSchema>;
16
+ export interface ComposeDiagnostic {
17
+ readonly path: string;
18
+ readonly message: string;
19
+ }
20
+ export interface ComposeResult {
21
+ readonly ok: boolean;
22
+ readonly profile: "admin-crud" | "ops-report" | "unknown";
23
+ /** Legal admin-crud returns the input spec unchanged (validate-only). */
24
+ readonly spec: unknown;
25
+ readonly diagnostics: readonly ComposeDiagnostic[];
26
+ readonly registrySource?: "project" | "snapshot";
27
+ readonly registryVersion?: string;
28
+ readonly warnings: readonly string[];
29
+ }
30
+ /**
31
+ * Profile-discriminated compose validation (tool-level).
32
+ * Registry resolve may walk the filesystem from `cwd` / `process.cwd()` when
33
+ * `resolvedRegistry` is not injected.
34
+ */
35
+ export declare function composeValidate(raw: unknown, options?: {
36
+ readonly cwd?: string;
37
+ readonly resolvedRegistry?: ResolvedBlockRegistry;
38
+ }): ComposeResult;
@@ -0,0 +1,143 @@
1
+ import { z } from "zod";
2
+ import { opsReportSpecSchema } from "./ops-report-schema.js";
3
+ import { blockNamesForView, resolveBlockRegistry, } from "./registry-resolve.js";
4
+ const adminKindSchema = z.enum(["list", "detail", "form"]);
5
+ const adminCrudInputSchema = z
6
+ .object({
7
+ profile: z.literal("admin-crud"),
8
+ kind: adminKindSchema,
9
+ /** Opaque page/spec payload; blocks[] validated against registry when present. */
10
+ spec: z.record(z.string(), z.unknown()),
11
+ })
12
+ .strict();
13
+ const opsReportInputSchema = z
14
+ .object({
15
+ profile: z.literal("ops-report"),
16
+ spec: z.unknown(),
17
+ })
18
+ .strict();
19
+ export const composeInputSchema = z.discriminatedUnion("profile", [
20
+ adminCrudInputSchema,
21
+ opsReportInputSchema,
22
+ ]);
23
+ function zodToDiagnostics(error) {
24
+ return error.issues.map((issue) => ({
25
+ path: issue.path.length > 0 ? issue.path.join(".") : "(root)",
26
+ message: issue.message,
27
+ }));
28
+ }
29
+ function validateAdminBlocks(kind, spec, resolved) {
30
+ const diagnostics = [];
31
+ const warnings = [];
32
+ const allowed = blockNamesForView(resolved.registry, kind);
33
+ const blocks = spec["blocks"];
34
+ if (blocks === undefined) {
35
+ warnings.push("admin-crud spec has no top-level blocks[]; registry types were not checked (validate-only passthrough).");
36
+ return { diagnostics, warnings };
37
+ }
38
+ if (!Array.isArray(blocks)) {
39
+ diagnostics.push({ path: "spec.blocks", message: "blocks must be an array" });
40
+ return { diagnostics, warnings };
41
+ }
42
+ for (const [index, block] of blocks.entries()) {
43
+ if (block === null || typeof block !== "object" || Array.isArray(block)) {
44
+ diagnostics.push({ path: `spec.blocks.${index}`, message: "block must be an object" });
45
+ continue;
46
+ }
47
+ const type = block["type"];
48
+ if (typeof type !== "string" || type.length === 0) {
49
+ diagnostics.push({
50
+ path: `spec.blocks.${index}.type`,
51
+ message: "block requires string type",
52
+ });
53
+ continue;
54
+ }
55
+ if (!allowed.has(type)) {
56
+ diagnostics.push({
57
+ path: `spec.blocks.${index}.type`,
58
+ message: `illegal block type "${type}" for ${kind}; allowed: ${[...allowed].sort().join(", ")}`,
59
+ });
60
+ }
61
+ }
62
+ return { diagnostics, warnings };
63
+ }
64
+ /**
65
+ * Profile-discriminated compose validation (tool-level).
66
+ * Registry resolve may walk the filesystem from `cwd` / `process.cwd()` when
67
+ * `resolvedRegistry` is not injected.
68
+ */
69
+ export function composeValidate(raw, options = {}) {
70
+ const parsed = composeInputSchema.safeParse(raw);
71
+ if (!parsed.success) {
72
+ return {
73
+ ok: false,
74
+ profile: "unknown",
75
+ spec: null,
76
+ diagnostics: zodToDiagnostics(parsed.error),
77
+ warnings: [],
78
+ };
79
+ }
80
+ const input = parsed.data;
81
+ /** Prefer original caller object identity for admin-crud passthrough. */
82
+ const originalSpec = raw !== null && typeof raw === "object" && !Array.isArray(raw) && "spec" in raw
83
+ ? raw.spec
84
+ : input.spec;
85
+ if (input.profile === "ops-report") {
86
+ const ops = opsReportSpecSchema.safeParse(input.spec);
87
+ if (!ops.success) {
88
+ return {
89
+ ok: false,
90
+ profile: "ops-report",
91
+ spec: originalSpec,
92
+ diagnostics: zodToDiagnostics(ops.error),
93
+ warnings: [],
94
+ };
95
+ }
96
+ return {
97
+ ok: true,
98
+ profile: "ops-report",
99
+ spec: ops.data,
100
+ diagnostics: [],
101
+ warnings: [],
102
+ };
103
+ }
104
+ // admin-crud: validate-only — return caller's spec object unchanged when legal
105
+ let resolved = options.resolvedRegistry;
106
+ if (!resolved) {
107
+ try {
108
+ resolved = resolveBlockRegistry({ cwd: options.cwd });
109
+ }
110
+ catch (error) {
111
+ const message = error instanceof Error ? error.message : String(error);
112
+ return {
113
+ ok: false,
114
+ profile: "admin-crud",
115
+ spec: originalSpec,
116
+ diagnostics: [{ path: "registry", message }],
117
+ warnings: [],
118
+ };
119
+ }
120
+ }
121
+ const { diagnostics: blockDiagnostics, warnings: blockWarnings } = validateAdminBlocks(input.kind, input.spec, resolved);
122
+ const warnings = [...resolved.warnings, ...blockWarnings];
123
+ if (blockDiagnostics.length > 0) {
124
+ return {
125
+ ok: false,
126
+ profile: "admin-crud",
127
+ spec: originalSpec,
128
+ diagnostics: blockDiagnostics,
129
+ registrySource: resolved.source,
130
+ registryVersion: resolved.registryVersion,
131
+ warnings,
132
+ };
133
+ }
134
+ return {
135
+ ok: true,
136
+ profile: "admin-crud",
137
+ spec: originalSpec,
138
+ diagnostics: [],
139
+ registrySource: resolved.source,
140
+ registryVersion: resolved.registryVersion,
141
+ warnings,
142
+ };
143
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Single HTML encode chokepoint for compose/ops-report output.
3
+ * All untrusted strings must pass through here before interpolation.
4
+ */
5
+ export declare function escapeHtml(value: string): string;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Single HTML encode chokepoint for compose/ops-report output.
3
+ * All untrusted strings must pass through here before interpolation.
4
+ */
5
+ export function escapeHtml(value) {
6
+ return value
7
+ .replaceAll("&", "&amp;")
8
+ .replaceAll("<", "&lt;")
9
+ .replaceAll(">", "&gt;")
10
+ .replaceAll('"', "&quot;")
11
+ .replaceAll("'", "&#39;");
12
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Render a validated OpsReportSpec to self-contained HTML.
3
+ * Callers should pass already-validated specs; this re-parses for safety.
4
+ */
5
+ export declare function renderOpsReportHtml(input: unknown): string;