@zq-silk/yui 2.11.0 → 2.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +5 -0
  2. package/dist/artifacts/artifactCapability.js +14 -8
  3. package/dist/artifacts/artifactCommitLock.js +7 -5
  4. package/dist/artifacts/jobArtifact.js +93 -0
  5. package/dist/artifacts/jobArtifactTransport.js +13 -0
  6. package/dist/artifacts/taskArtifactRepository.js +58 -10
  7. package/dist/cli/commandCatalog.js +45 -14
  8. package/dist/cli/commandDiscovery.js +1 -1
  9. package/dist/commands/durableJobCommands.js +31 -6
  10. package/dist/commands/globalRoleCommands.js +5 -0
  11. package/dist/commands/scheduleCommands.js +14 -3
  12. package/dist/commands/taskCommands.js +5 -2
  13. package/dist/context/taskCatalog.js +6 -1
  14. package/dist/controlPlaneCli.js +7 -2
  15. package/dist/controller/agentJobControl.js +391 -0
  16. package/dist/controller/controller.js +24 -3
  17. package/dist/controller/fileSchedulerStoreAdapter.js +12 -1
  18. package/dist/controller/globalInputDelivery.js +8 -1
  19. package/dist/controller/jobArtifactControl.js +111 -0
  20. package/dist/controller/jobControl.js +4 -0
  21. package/dist/controller/jobDefinitionControl.js +191 -19
  22. package/dist/controller/jobSupervisor.js +83 -3
  23. package/dist/controller/jobTaskDelegationControl.js +193 -0
  24. package/dist/controller/runtime.js +9 -1
  25. package/dist/controller/scheduleControl.js +83 -20
  26. package/dist/integration/gitIntegrationService.js +2 -0
  27. package/dist/job/agentJob.js +131 -0
  28. package/dist/job/agentJobObservation.js +61 -0
  29. package/dist/job/agentRoleScope.js +33 -0
  30. package/dist/job/durableJob.js +63 -28
  31. package/dist/job/jobAssignmentScope.js +1 -0
  32. package/dist/job/jobBusinessResult.js +72 -0
  33. package/dist/job/jobCleanupInspection.js +4 -0
  34. package/dist/job/jobDefinition.js +26 -2
  35. package/dist/job/jobRunner.js +4 -3
  36. package/dist/job/projectRefreshRunner.js +54 -18
  37. package/dist/job/projectSync.js +162 -0
  38. package/dist/job/scheduleService.js +55 -0
  39. package/dist/job/taskDelegation.js +48 -0
  40. package/dist/message/messageContinuation.js +1 -1
  41. package/dist/resources/projectResourceService.js +1 -1
  42. package/dist/resources/resourceGc.js +1 -1
  43. package/dist/role/role.js +12 -0
  44. package/dist/schedule/schedule.js +65 -15
  45. package/dist/scheduler/wakeReason.js +4 -0
  46. package/dist/storage/agentJobSchema.js +10 -0
  47. package/dist/storage/recordValidation.js +2 -0
  48. package/dist/storage/sqliteSchema.js +77 -6
  49. package/dist/storage/sqliteStore.js +53 -0
  50. package/dist/storage/storageVersions.js +1 -1
  51. package/dist/storage/storeRpc.js +3 -0
  52. package/dist/storage/taskCatalog.js +10 -2
  53. package/dist/storage/upgrade/jobBusinessResultMigration.js +20 -0
  54. package/dist/storage/upgrade/scheduleDispatchMigration.js +15 -0
  55. package/dist/task/archivePreflight.js +15 -0
  56. package/dist/task/completionReadiness.js +7 -0
  57. package/dist/task/task.js +17 -1
  58. package/dist/verification/verificationGateService.js +4 -0
  59. package/dist/web/assets/client/app/catalog.js +16 -0
  60. package/dist/web/assets/client/app/settings.js +4 -0
  61. package/dist/web/assets/client/i18n/en.js +75 -2
  62. package/dist/web/assets/client/i18n/zh-CN.js +75 -2
  63. package/dist/web/assets/client/index.js +2 -0
  64. package/dist/web/assets/client/layout/manageShell.js +9 -0
  65. package/dist/web/assets/client/layout/router.js +1 -0
  66. package/dist/web/assets/client/views/jobs.js +166 -37
  67. package/dist/web/assets/client/views/permissions.js +129 -0
  68. package/dist/web/assets/client/views/task/files.js +5 -4
  69. package/dist/web/assets/client/views/task/page.js +5 -1
  70. package/dist/web/assets/shared/manageNav.js +1 -0
  71. package/dist/web/assets/shell/settingsContent.js +4 -0
  72. package/dist/web/assets/styles/views/settings.js +13 -0
  73. package/dist/web/taskPreviews.js +2 -2
  74. package/dist/web/webJobs.js +36 -11
  75. package/dist/web/webMaterials.js +21 -9
  76. package/dist/web/webPermissions.js +97 -0
  77. package/dist/web/webServer.js +44 -5
  78. package/dist/web/webTaskSurface.js +1 -0
  79. package/docs/authorization.md +35 -0
  80. package/docs/job-runs.md +392 -17
  81. package/docs/release-workflow.md +77 -0
  82. package/docs/release-workflow.zh-CN.md +57 -0
  83. package/docs/schedules.md +69 -15
  84. package/docs/storage-baseline.md +38 -1
  85. package/docs/storage-baseline.zh-CN.md +27 -1
  86. package/docs/testing/verification-levels.md +2 -2
  87. package/docs/testing/verification-levels.zh-CN.md +2 -2
  88. package/i18n/README.zh-CN.md +4 -0
  89. package/package.json +1 -1
@@ -5,8 +5,8 @@ import { requireKnownFields } from "../domain/validation.js";
5
5
  import { WebRequestRejected, webLocalMutation } from "./webMutation.js";
6
6
  /** Local-user ingress only, behind the Web token/origin boundary. Never use
7
7
  * this adapter for managed RPC callers. The route fixes scope; the server
8
- * resolves the current Operator binding on every request. No invented Session
9
- * or durable second authority: the public services revalidate the binding.
8
+ * uses public user authority for global controlled actions. Task shell access
9
+ * still resolves the current Operator binding; no invented Session.
10
10
  */
11
11
  export function createWebJobs(store, home) {
12
12
  function invoke(scope, family, value, write, now) {
@@ -14,21 +14,31 @@ export function createWebJobs(store, home) {
14
14
  if (!value || typeof value !== "object" || Array.isArray(value))
15
15
  throw new Error("Expected an operation.");
16
16
  requireKnownFields(value, family === "jobs"
17
- ? ["op", "id", "title", "configuration", "expectedRevision", "runId", "requestId"]
18
- : ["op", "id", "jobId", "rule", "expectedRevision"], "Web Job/Schedule request");
17
+ ? ["op", "id", "title", "configuration", "expectedRevision", "runId", "requestId",
18
+ "reference", "offset", "expectedSessionId"]
19
+ : ["op", "id", "jobId", "rule", "expectedRevision", "authorization", "target"], "Web Job/Schedule request");
19
20
  const input = value;
20
- const reads = family === "jobs" ? ["list", "show", "runs", "run-get", "logs"] : ["list", "show"];
21
- const writes = family === "jobs" ? ["define", "update", "run", "run-cancel"] : ["create", "update", "enable", "disable"];
21
+ const reads = family === "jobs" ? ["list", "show", "runs", "run-get", "run-result", "run-evidence",
22
+ "run-delegation", "logs", "artifact-list", "artifact-preview", "session-inspect"] : ["list", "show"];
23
+ const writes = family === "jobs" ? ["define", "update", "run", "run-cancel", "artifact-register",
24
+ "session-stop", "session-new"] : ["create", "update", "enable", "disable"];
22
25
  if (!(write ? writes : reads).includes(String(input.op)))
23
26
  throw new Error("Operation is not available on this endpoint.");
24
27
  if (scope.kind === "task" && !tx.getTask(scope.taskId))
25
28
  throw new Error("Task not found.");
26
- const session = activeLiveRoleAgentSession(tx.getGlobalRoleSessionSet("operator"));
27
- if (!session)
28
- throw new Error("A current Operator Session is required for Job management.");
29
- const caller = { scope: "global", role: "operator", nativeSessionId: session.nativeSessionId,
30
- agentId: session.agentId, adapterId: session.adapterId };
29
+ const session = scope.kind === "task" ? activeLiveRoleAgentSession(tx.getGlobalRoleSessionSet("operator")) : null;
30
+ if (scope.kind === "task" && !session)
31
+ throw new Error("A current Operator Session is required for Task Job management.");
32
+ const caller = session ? { scope: "global", role: "operator", nativeSessionId: session.nativeSessionId,
33
+ agentId: session.agentId, adapterId: session.adapterId } : { scope: "user" };
31
34
  const { requestId, ...fields } = input;
35
+ if (typeof fields.reference === "string")
36
+ fields.reference = JSON.parse(fields.reference);
37
+ if (fields.offset !== undefined) {
38
+ if (!/^\d{1,9}$/u.test(String(fields.offset)))
39
+ throw new Error("Invalid artifact offset.");
40
+ fields.offset = Number(fields.offset);
41
+ }
32
42
  if (input.op === "run" && (typeof requestId !== "string" || !requestId.trim() || requestId.length > 200)) {
33
43
  throw new Error("Immediate execution requires a stable requestId.");
34
44
  }
@@ -36,6 +46,21 @@ export function createWebJobs(store, home) {
36
46
  ...(input.op === "run" ? { trigger: { source: "manual", key: requestId } } : {}) };
37
47
  return (family === "jobs" ? createJobDefinitionControl(tx, home) : createScheduleControl(tx, home))(params, now);
38
48
  };
49
+ // Artifact Git I/O is asynchronous, with its own authorization recheck and
50
+ // short publication transaction. Never keep a SQLite transaction across it.
51
+ if (family === "jobs" && value && typeof value === "object"
52
+ && "op" in value && String(value.op).startsWith("artifact-")) {
53
+ try {
54
+ return Promise.resolve(apply(store)).catch(error => {
55
+ if (!write)
56
+ throw new WebRequestRejected(error instanceof Error ? error.message : String(error));
57
+ throw error; // A Git/publication failure is not proof of non-submission.
58
+ });
59
+ }
60
+ catch (error) {
61
+ throw new WebRequestRejected(error instanceof Error ? error.message : String(error));
62
+ }
63
+ }
39
64
  if (write)
40
65
  return webLocalMutation(store, apply);
41
66
  try {
@@ -30,24 +30,36 @@ async function fixedImage(home, taskId, input) {
30
30
  /** One selected immutable file, never a workspace path or a remote URL.
31
31
  * Markdown is bounded and whole so fences/tables are not split by pagination. */
32
32
  export async function readArtifactPreview(home, taskId, ref, offset = 0) {
33
- if (/\.(svg|gif|avif|bmp|ico|tiff?)$/iu.test(ref.relativePath)) {
33
+ if (ref.taskId !== taskId)
34
+ throw new WebRequestRejected("Materials must belong to the same Task.");
35
+ const valid = validateGitArtifactRef(ref);
36
+ const file = await resolveGitArtifact(home, valid);
37
+ return { ...valid, ...previewArtifactContent(file, offset) };
38
+ }
39
+ /** Shared data-only preview for Task and Job evidence; no path lookup or authority. */
40
+ export function previewArtifactContent(file, offset = 0) {
41
+ if (/\.(svg|gif|avif|bmp|ico|tiff?)$/iu.test(file.relativePath)) {
34
42
  throw new WebRequestRejected("Unsupported image format. Save a static PNG, JPEG or WebP instead.");
35
43
  }
36
- if (isRasterArtifact(ref.relativePath)) {
44
+ if (file.relativePath.length > 512)
45
+ throw new WebRequestRejected("Artifact path is too long.");
46
+ if (isRasterArtifact(file.relativePath)) {
37
47
  if (offset !== 0)
38
48
  throw new WebRequestRejected("Images do not have text offsets.");
39
- return fixedImage(home, taskId, ref);
49
+ return { digest: file.digest, kind: "image", byteSize: file.bytes.length,
50
+ ...rasterMetadata(file.bytes), base64: file.bytes.toString("base64") };
40
51
  }
41
- if (/\.md$|\.markdown$/iu.test(ref.relativePath)) {
52
+ textPath(file.relativePath);
53
+ const text = decodeText(file.bytes);
54
+ if (/\.md$|\.markdown$/iu.test(file.relativePath)) {
42
55
  if (offset !== 0)
43
56
  throw new WebRequestRejected("Markdown previews start at offset zero.");
44
- const file = await fixedText(home, taskId, ref);
45
- if (file.bytes > MAX_MATERIAL_BYTES)
57
+ if (file.bytes.length > MAX_MATERIAL_BYTES)
46
58
  throw new WebRequestRejected("Markdown preview exceeds 256 KiB; split the document.");
47
- return { ...file.ref, kind: "markdown", byteSize: file.bytes, content: file.text,
48
- offset: 0, totalCharacters: file.text.length, nextOffset: null };
59
+ return { digest: file.digest, kind: "markdown", byteSize: file.bytes.length, content: text,
60
+ offset: 0, totalCharacters: text.length, nextOffset: null };
49
61
  }
50
- return readTextArtifactPage(home, taskId, ref, offset);
62
+ return { digest: file.digest, byteSize: file.bytes.length, ...page(text, offset) };
51
63
  }
52
64
  function textPath(path) {
53
65
  const safe = safeRelativeArtifactPath(path);
@@ -0,0 +1,97 @@
1
+ import { ROOT_COMMAND } from "../cli/commandCatalog.js";
2
+ import { discoveryCommandTree } from "../cli/commandDiscovery.js";
3
+ import { BUILTIN_CAPABILITIES } from "../kernel/builtinCapabilities.js";
4
+ const templates = [
5
+ { id: "public", audience: "public" },
6
+ { id: "operator", audience: "operator" },
7
+ { id: "global", audience: "global" },
8
+ { id: "leader", audience: "leader" },
9
+ { id: "worker", audience: "assignment" },
10
+ { id: "reviewer", audience: "assignment" },
11
+ { id: "unbound", audience: "unbound" }
12
+ ];
13
+ function executableNodes(node) {
14
+ return [...(node.kind !== "group" && node.discovery?.surface !== "runtime" ? [node] : []),
15
+ ...node.children.flatMap(executableNodes)];
16
+ }
17
+ const commands = executableNodes(ROOT_COMMAND);
18
+ // The same pure projection as CLI help/completion. No fake Session, caller
19
+ // environment, authority issuance, registry invocation or domain assessment.
20
+ const projections = new Map(templates.map(role => [role.audience,
21
+ new Map(executableNodes(discoveryCommandTree({}, role.audience)).map(node => [node.path.join(" "), node]))]));
22
+ const pageSize = 30;
23
+ function searchText(value = "") {
24
+ if (value.length > 200)
25
+ throw new Error("Search is limited to 200 characters.");
26
+ return value.trim().toLowerCase();
27
+ }
28
+ function page(items, cursor = "0") {
29
+ if (!/^(0|[1-9]\d*)$/.test(cursor) || !Number.isSafeInteger(Number(cursor)))
30
+ throw new Error("Invalid cursor.");
31
+ const start = Number(cursor);
32
+ return { items: items.slice(start, start + pageSize), total: items.length,
33
+ nextCursor: start + pageSize < items.length ? String(start + pageSize) : null };
34
+ }
35
+ /** Read-only metadata projection for the authenticated local Web user.
36
+ * Only configured GlobalRole names cross the Store boundary. Task contents,
37
+ * bindings, profiles, Sessions, Grants and plugin descriptors are not read.
38
+ * These templates deliberately have no AccessAssessment field. */
39
+ export function createWebPermissions(store) {
40
+ const roles = () => [...templates, ...store.listGlobalRoles()
41
+ .filter(role => role.name !== "operator").map(role => ({
42
+ id: `global:${role.name}`, name: role.name, audience: "global"
43
+ }))];
44
+ const selected = (id) => {
45
+ if (!id)
46
+ return templates;
47
+ const role = roles().find(role => role.id === id);
48
+ if (!role)
49
+ throw new Error("Unknown role template.");
50
+ return [role];
51
+ };
52
+ const cells = (id, selectedRoles) => selectedRoles.map(role => {
53
+ const projected = projections.get(role.audience).get(id);
54
+ return { role: role.id, visible: !!projected };
55
+ });
56
+ return {
57
+ index() {
58
+ return { roles: roles(), categories: [...new Set(commands.map(node => node.path[1]))].sort(),
59
+ sources: ["cli/commandCatalog.ts", "cli/commandDiscovery.ts", "kernel/builtinCapabilities.ts", "docs/authorization.md"],
60
+ evaluation: "not-performed" };
61
+ },
62
+ commands(query = {}) {
63
+ const q = searchText(query.q);
64
+ const selectedRoles = selected(query.role);
65
+ return { ...page(commands.filter(node => (!query.category || node.path[1] === query.category)
66
+ && `${node.path.join(" ")} ${node.summary}`.toLowerCase().includes(q))
67
+ .map(node => ({ id: node.path.join(" "), summary: node.summary, category: node.path[1],
68
+ roles: cells(node.path.join(" "), selectedRoles) })), query.cursor),
69
+ evaluation: "not-performed" };
70
+ },
71
+ command(id, role) {
72
+ const node = commands.find(node => node.path.join(" ") === id);
73
+ if (!node)
74
+ throw new Error("Unknown command template.");
75
+ return { id, summary: node.summary, evaluation: "not-performed",
76
+ source: "cli/commandCatalog.ts → cli/commandDiscovery.ts",
77
+ roles: selected(role).map(role => {
78
+ const projected = projections.get(role.audience).get(id);
79
+ return { role: role.id, audience: role.audience, visible: !!projected,
80
+ // Use the projected usage/options, including option audience gates.
81
+ usage: projected?.usage ?? [], options: projected?.options ?? [] };
82
+ }) };
83
+ },
84
+ capabilities(query = {}) {
85
+ const q = searchText(query.q);
86
+ return { ...page(BUILTIN_CAPABILITIES
87
+ .filter(entry => `${entry.name} ${entry.summary} ${entry.requiredPermissions.join(" ")}`.toLowerCase().includes(q))
88
+ .map(entry => ({
89
+ name: entry.name, summary: entry.summary, effect: entry.effect, source: entry.source,
90
+ requiredPermissions: entry.requiredPermissions,
91
+ requiredInputs: entry.inputSchema.required ?? [],
92
+ inputs: Object.keys(entry.inputSchema.properties ?? {})
93
+ })), query.cursor), evaluation: "not-performed",
94
+ source: "kernel/builtinCapabilities.ts (registry built-in descriptors)" };
95
+ }
96
+ };
97
+ }
@@ -126,6 +126,44 @@ async function handleHttpRequest(request, response, store, dependencies, token,
126
126
  sendJson(response, 403, { error: "Invalid Yui web token.", disposition: "not-submitted" }, method === "HEAD");
127
127
  return;
128
128
  }
129
+ if (pathname === "/api/permissions" || pathname.startsWith("/api/permissions/")) {
130
+ if (method !== "GET") {
131
+ sendJson(response, 405, { error: "Permission templates are read-only.", disposition: "not-submitted" }, method === "HEAD");
132
+ return;
133
+ }
134
+ const allowed = {
135
+ "/api/permissions": [],
136
+ "/api/permissions/commands": ["q", "role", "category", "cursor"],
137
+ "/api/permissions/command": ["id", "role"],
138
+ "/api/permissions/capabilities": ["q", "cursor"]
139
+ };
140
+ if (!Object.hasOwn(allowed, pathname)) {
141
+ sendJson(response, 404, { error: "Unknown permission template route." }, false);
142
+ return;
143
+ }
144
+ if (!dependencies.permissions) {
145
+ sendJson(response, 503, { error: "Permission templates unavailable." }, false);
146
+ return;
147
+ }
148
+ try {
149
+ const query = new URL(request.url, "http://localhost").searchParams;
150
+ if ([...query.keys()].some(key => !allowed[pathname].includes(key) || query.getAll(key).length !== 1)) {
151
+ throw new Error("Unknown or repeated template query.");
152
+ }
153
+ const params = Object.fromEntries(query);
154
+ const service = dependencies.permissions;
155
+ const value = pathname === "/api/permissions" ? service.index()
156
+ : pathname.endsWith("/commands") ? service.commands(params)
157
+ : pathname.endsWith("/command") ? service.command(params.id ?? "", params.role)
158
+ : service.capabilities(params);
159
+ sendJson(response, 200, value, false);
160
+ }
161
+ catch (error) {
162
+ sendJson(response, 400, { error: error instanceof Error ? error.message : "Invalid template query.",
163
+ disposition: "not-submitted" }, false);
164
+ }
165
+ return;
166
+ }
129
167
  if (pathname.startsWith("/preview/") && dependencies.previews) {
130
168
  await dependencies.previews.serve(request, response);
131
169
  return;
@@ -284,15 +322,16 @@ async function handleHttpRequest(request, response, store, dependencies, token,
284
322
  let value;
285
323
  if (method === "GET") {
286
324
  const query = new URL(request.url, "http://localhost").searchParams;
287
- if ([...query.keys()].some(key => !["op", "id", "runId"].includes(key)))
288
- throw new WebRequestRejected("Unknown Job query.");
289
- value = dependencies.jobs.read(scope, family, Object.fromEntries(query), now());
325
+ if ([...query.keys()].some(key => !["op", "id", "runId", "reference", "offset"].includes(key)
326
+ || query.getAll(key).length !== 1))
327
+ throw new WebRequestRejected("Unknown or repeated Job query.");
328
+ value = await dependencies.jobs.read(scope, family, Object.fromEntries(query), now());
290
329
  }
291
330
  else if (method === "POST") {
292
331
  if (headerValue(request, "origin") !== `http://${headerValue(request, "host")}`) {
293
332
  throw new WebRequestRejected("Job changes require a same-origin request.");
294
333
  }
295
- value = dependencies.jobs.mutate(scope, family, await readMutationBody(request), now());
334
+ value = await dependencies.jobs.mutate(scope, family, await readMutationBody(request), now());
296
335
  }
297
336
  else
298
337
  throw new WebRequestRejected("Method not allowed.");
@@ -631,7 +670,7 @@ async function handleHttpRequest(request, response, store, dependencies, token,
631
670
  try {
632
671
  const assetVersion = new URL(request.url, "http://localhost").searchParams.get("v");
633
672
  const asset = findWebAsset(pathname, assetVersion);
634
- const managementPage = /^\/(?:models(?:\/(?:providers|catalog|quota))?|settings(?:\/(?:preferences|skills|server|connection))?)\/?$/.test(pathname);
673
+ const managementPage = /^\/(?:models(?:\/(?:providers|catalog|quota))?|settings(?:\/(?:preferences|skills|jobs|permissions|server|connection))?)\/?$/.test(pathname);
635
674
  if (pathname === "/" || pathname === "/index.html" || managementPage) {
636
675
  sendText(response, 200, "text/html; charset=utf-8", prepareWebDocument(managementPage ? MODELS_HTML.replace("__YUI_WEB_TOKEN__", token) : dashboardHtml(token), managementPage ? "/assets/js/app/manage.js" : "/assets/app.js"), method === "HEAD");
637
676
  }
@@ -53,6 +53,7 @@ export function createWebTaskSurface(store, options = {}, observations = [], hos
53
53
  core: readTaskContext(tx, taskId, environment),
54
54
  task: {
55
55
  id: task.id, title: clip(task.title), status: task.status, updatedAt: task.updatedAt,
56
+ source: task.source,
56
57
  executionGate: task.executionGate, priority: task.priority, tags: task.tags?.slice(0, 16).map(value => clip(value)),
57
58
  projectBindings: task.projectBindings.slice(0, 32),
58
59
  description: clip(task.description), completionSummary: clip(task.completionSummary),
@@ -111,6 +111,41 @@ remains a limited template, while execution reports the original storage/identit
111
111
  failure. Complete launch hints can still show a role's static help on a broken
112
112
  Home; they cannot authorize reads or effects.
113
113
 
114
+ ## Read-only Web templates
115
+
116
+ Open **Settings → Command permissions** (`/settings/permissions`) to compare
117
+ CLI discovery for ordinary terminals/unmanaged sessions, Operator, GlobalRole,
118
+ Task Leader, Worker and Reviewer. Configured GlobalRole names are selectable;
119
+ they use the existing global discovery audience, not a new role impersonation
120
+ path. Search, category/role filters and 30-row pages keep the view bounded.
121
+ Expand a command for its audience-filtered usage and scope explanation.
122
+
123
+ “Visible” means the existing `discoveryCommandTree` retains the executable
124
+ command; “Hidden” means it does not. Neither is an exact authorization
125
+ assessment. Worker and Reviewer can share a template while having different
126
+ Assignment/Review and workspace constraints. Planning/delivery, lifecycle,
127
+ revocation and resource/Grant checks remain in their existing execution owners.
128
+ The directory cannot express every domain condition and this page does not
129
+ attempt to infer them from command names.
130
+
131
+ The adjacent **Built-in capability contracts** view projects
132
+ `BUILTIN_CAPABILITIES`, the same descriptors registered in `CapabilityRegistry`,
133
+ including required permission categories, input names, declared effect and
134
+ semantic source. It does not invent a CLI-to-capability mapping or interpret
135
+ those categories as role grants. Task-local plugin descriptors are excluded.
136
+ Exact `hidden` / `requestable` / `authorized` observations still require the
137
+ existing authenticated discovery path and target input; this page performs no
138
+ such assessment and emits no `access` field.
139
+
140
+ The `/api/permissions` metadata, `/commands`, `/command` and `/capabilities`
141
+ routes are GET-only, use the existing Web authentication, Host/Origin and page
142
+ token protection, and reject target/identity arguments. The only Store read is
143
+ the configured global role list, projected to names. No Session, Task contents,
144
+ Grant, plugin instance or Provider is read/acquired, and no operation is invoked.
145
+ New catalog commands enter automatically; do not maintain a Web ACL.
146
+ `web-permissions.test.js` checks full projection coverage, option filtering,
147
+ private-read/write exclusion and the HTTP read-only boundary.
148
+
114
149
  ## Verification and maintenance
115
150
 
116
151
  `cli-authority.test.js` exercises isolated launcher-backed public reads, Draft