@ultimat3/cli 25.2.0 → 26.0.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.
package/README.md CHANGED
@@ -19,9 +19,9 @@ Commands and the `x verify` step count, `As of 2026-08`:
19
19
  | `x doctor` | environment, ports, drift, PWA prerequisites, `APP_URL` | every finding carries a fix command; probes the port `x dev` binds ([which one](../../wiki/CLI-Reference.md#x-doctor)); the framework's deploy secrets only where the machine names `staging`/`production` |
20
20
  | `x deploy` | container deploy plan | compose or helm; zero platform primitives |
21
21
  | `x manifest` / `x routes` | generated facts | `x.manifest.json`, `openapi.json`, route table |
22
- | `x actions` / `x queries` / `x entities` | the declaration registries | `list` and `describe <name>`, straight off the registries |
22
+ | `x actions` / `x queries` / `x entities` | the declaration registries | `list` and `show <name>`, straight off the registries |
23
23
  | `x tasks list\|show` | cron tasks | timezone and next run, off `registeredTasks()` |
24
- | `x jobs ls\|show\|retry\|cancel\|rm\|promote\|pause\|resume` | the queue | depth, dead letters, step traces, `retry --from-step`, `cancel --reason`; `ls --json` pages as `rows`/`nextCursor`/`hasMore`; `drain` is planned (`X_NOT_IMPLEMENTED`) |
24
+ | `x jobs list\|show\|retry\|cancel\|delete\|promote\|pause\|resume` | the queue | depth, dead letters, step traces, `retry --from-step`, `cancel --reason`; `list --json` pages as `rows`/`nextCursor`/`hasMore`; `drain` is planned (`X_NOT_IMPLEMENTED`) |
25
25
  | `x test [type]` | one of the six test types, or all | same type rule as the gate; `--filter`, `--sample N` |
26
26
  | `x env check\|example` | the typed environment `envSchema` declares | and the `.env.example` rendered from it, plus the framework's deploy-required secrets (`ULTIMATE_CURSOR_SECRET`, `STORAGE_SIGNING_SECRET` unless `S3_ENDPOINT` is set), which `check` asks for wherever the boot refuses them — outside `development`/`test`, or with no environment named |
27
27
  | `x auth seal-mfa` | one-shot auth maintenance | seals every `x_users.mfa_secret` still in the clear; idempotent, `{ sealed, alreadySealed, skipped }` |
@@ -171,7 +171,7 @@ that name where there is one (`x new` writes all seventeen), the blank template
171
171
  | `app-boundaries.ts` | app import boundaries, over `@ultimat3/render`'s surface check |
172
172
  | `app-transport.ts` / `browser-transport.ts` | one browser transport, on `boundaries` in every app: a raw `fetch(`, `new WebSocket(` or `new XMLHttpRequest(` in an island's closure is `X_BROWSER_TRANSPORT_BYPASS`; a server barrel there is `X_BROWSER_SERVER_BARREL` |
173
173
  | `app-agents-md.ts` | `AGENTS.md` exists and stays short, over `@ultimat3/manifest`'s check |
174
- | `serve.ts` | **what a container starts** — `runRole(options)`, the same boot `x dev` runs minus the watcher, `/_x` and `dev: true`. `x new`'s `apps/web/server.ts` is three lines that call it. `ROLE`, `PORT` and `HOST` are read from `env`; `role`, `port` and `hostname` on `ServeOptions` override each. `ROLE=worker` also retires on SIGUSR2 (`serve-retire.ts`, 25.0.0): stop claiming, finish every held job, abort nothing, drain, exit 0 — what the chart's opt-in `roles.worker.retireSeconds` `preStop` sends; a no-op on Windows ([Deployment](../../wiki/Deployment.md#retiring-a-worker-before-sigterm)) |
174
+ | `serve.ts` | **what a container starts** — `runRole(options)`, the same boot `x dev` runs minus the watcher, `/_x` and `dev: true`. `x new`'s `apps/web/server.ts` is three lines that call it. `ROLE`, `PORT` and `HOST` are read from `env`; `role`, `port` and `hostname` on `ServeOptions` override each. `ROLE=worker` also retires on SIGUSR2 (`serve-retire.ts`, 25.0.0): stop claiming, finish every held job, abort nothing, drain, exit 0 — what the chart's opt-in `roles.worker.retireSeconds` `preStop` sends; core's `isRetiring()` is true from the signal on, and a SIGTERM mid-retire drains on `drain.workerDeadlineMs` when declared (`lifecycleForRole`); a no-op on Windows ([Deployment](../../wiki/Deployment.md#retiring-a-worker-before-sigterm)) |
175
175
  | `prerender.ts` | `x build --target static`: which `site/` routes qualify, and where the bytes land |
176
176
  | `metrics-endpoint.ts` | the `METRICS_PATH` scrape listener every role opens, on `METRICS_PORT`; opening it starts the `process_*` series, labelled by `startMetricsEndpoint({ role })` |
177
177
  | `role-load.ts` / `document-graph.ts` / `serve-web.ts` | what each container role imports: `web`, `sync` and `replicator` every app module; `worker` and `scheduler` the API index plus every module that reaches no component or stylesheet |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cli",
3
- "version": "25.2.0",
3
+ "version": "26.0.0",
4
4
  "description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -39,33 +39,33 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "@babel/core": "^8.0.1",
42
- "@ultimat3/action": "25.2.0",
43
- "@ultimat3/admin": "25.2.0",
44
- "@ultimat3/ai": "25.2.0",
45
- "@ultimat3/auth": "25.2.0",
46
- "@ultimat3/cache": "25.2.0",
47
- "@ultimat3/core": "25.2.0",
48
- "@ultimat3/db": "25.2.0",
49
- "@ultimat3/entity": "25.2.0",
50
- "@ultimat3/flags": "25.2.0",
51
- "@ultimat3/http": "25.2.0",
52
- "@ultimat3/i18n": "25.2.0",
53
- "@ultimat3/jobs": "25.2.0",
54
- "@ultimat3/mail": "25.2.0",
55
- "@ultimat3/manifest": "25.2.0",
56
- "@ultimat3/mcp": "25.2.0",
57
- "@ultimat3/money": "25.2.0",
58
- "@ultimat3/notify": "25.2.0",
59
- "@ultimat3/policy": "25.2.0",
60
- "@ultimat3/pwa": "25.2.0",
61
- "@ultimat3/query": "25.2.0",
62
- "@ultimat3/realtime": "25.2.0",
63
- "@ultimat3/render": "25.2.0",
64
- "@ultimat3/schema": "25.2.0",
65
- "@ultimat3/seo": "25.2.0",
66
- "@ultimat3/storage": "25.2.0",
67
- "@ultimat3/testing": "25.2.0",
68
- "@ultimat3/time": "25.2.0",
42
+ "@ultimat3/action": "26.0.0",
43
+ "@ultimat3/admin": "26.0.0",
44
+ "@ultimat3/ai": "26.0.0",
45
+ "@ultimat3/auth": "26.0.0",
46
+ "@ultimat3/cache": "26.0.0",
47
+ "@ultimat3/core": "26.0.0",
48
+ "@ultimat3/db": "26.0.0",
49
+ "@ultimat3/entity": "26.0.0",
50
+ "@ultimat3/flags": "26.0.0",
51
+ "@ultimat3/http": "26.0.0",
52
+ "@ultimat3/i18n": "26.0.0",
53
+ "@ultimat3/jobs": "26.0.0",
54
+ "@ultimat3/mail": "26.0.0",
55
+ "@ultimat3/manifest": "26.0.0",
56
+ "@ultimat3/mcp": "26.0.0",
57
+ "@ultimat3/money": "26.0.0",
58
+ "@ultimat3/notify": "26.0.0",
59
+ "@ultimat3/policy": "26.0.0",
60
+ "@ultimat3/pwa": "26.0.0",
61
+ "@ultimat3/query": "26.0.0",
62
+ "@ultimat3/realtime": "26.0.0",
63
+ "@ultimat3/render": "26.0.0",
64
+ "@ultimat3/schema": "26.0.0",
65
+ "@ultimat3/seo": "26.0.0",
66
+ "@ultimat3/storage": "26.0.0",
67
+ "@ultimat3/testing": "26.0.0",
68
+ "@ultimat3/time": "26.0.0",
69
69
  "babel-preset-solid": "^1.9.15"
70
70
  }
71
71
  }
@@ -1,6 +1,7 @@
1
- // `x db branch ls|create|drop` — the wiring alone: which verb, which database, which refusal.
1
+ // `x db branch list|create|delete` — the wiring alone: which verb, which database, which refusal.
2
2
  // A VERB is required and comes from a closed set, so a branch name can never be read as one:
3
- // `x db branch ls` used to clone a database called `ls`, because the argument was the name.
3
+ // `x db branch ls` used to clone a database called `ls`, because the argument was the name. One
4
+ // verb each across every `x` registry (#709): `list` and `delete`, no `ls`/`drop` alias.
4
5
  // The facts (what a branch is, per mode) are `db-branch.ts`; the client lifetime is here.
5
6
 
6
7
  import { ERROR_DOCS_URL, nearestName } from '@ultimat3/core';
@@ -26,7 +27,7 @@ import { stepFinding } from './db-finding';
26
27
  import { MissingPositionalError, UnknownCommandError } from './errors';
27
28
  import { msg } from './messages';
28
29
  import type { CommandResult, Finding } from './output';
29
- import { flagString } from './parse';
30
+ import { flagString, retiredVerbReplacement } from './parse';
30
31
  import type { DevServices } from './runtime-bindings';
31
32
  import { resolveServices } from './runtime-bindings';
32
33
  import { portFromEnv } from './serve';
@@ -35,9 +36,9 @@ import { renderTable } from './table';
35
36
  /**
36
37
  * Always runnable, always the next thing a caller needs: what branches there are. Spelled twice
37
38
  * because `UnknownCommandError` prefixes its `suggestion` with `x ` and `MissingPositionalError`
38
- * takes a whole invocation — one of them handed back `x x db branch ls --json`.
39
+ * takes a whole invocation — one of them handed back `x x db branch list --json`.
39
40
  */
40
- const LIST_ARGV = 'db branch ls --json';
41
+ const LIST_ARGV = 'db branch list --json';
41
42
  const LIST_FIX = `x ${LIST_ARGV}`;
42
43
 
43
44
  /**
@@ -47,7 +48,10 @@ const LIST_FIX = `x ${LIST_ARGV}`;
47
48
  * `x` excluded — the error class adds it.
48
49
  */
49
50
  function branchRetry(word: string, name: string | undefined): string {
50
- const near = nearestName(word, [...BRANCH_SUBCOMMANDS]);
51
+ // A retired verb (`ls`, `drop`) is a legal branch NAME too, so without this `drop feat` would be
52
+ // answered with `create drop` — the exact confusion the closed verb set exists to end.
53
+ const near =
54
+ retiredVerbReplacement(word, BRANCH_SUBCOMMANDS) ?? nearestName(word, [...BRANCH_SUBCOMMANDS]);
51
55
  if (near !== undefined) return name === undefined ? LIST_ARGV : `db branch ${near} ${name}`;
52
56
  return isBranchName(word) ? `db branch create ${word}` : LIST_ARGV;
53
57
  }
@@ -101,7 +105,7 @@ export async function runBranchCommand(ctx: CommandContext, root: string): Promi
101
105
  });
102
106
  }
103
107
  const services = resolveServices(root, ctx.env);
104
- if (verb === 'ls') return runList(services);
108
+ if (verb === 'list') return runList(services);
105
109
  if (name === undefined) {
106
110
  throw new MissingPositionalError({
107
111
  command: `db branch ${verb}`,
@@ -109,7 +113,7 @@ export async function runBranchCommand(ctx: CommandContext, root: string): Promi
109
113
  example: `x db branch ${verb} feature-x`,
110
114
  });
111
115
  }
112
- return verb === 'create' ? runCreate(ctx, services, name) : runDrop(services, name);
116
+ return verb === 'create' ? runCreate(ctx, services, name) : runDelete(services, name);
113
117
  }
114
118
 
115
119
  const row = (branch: BranchRow): readonly string[] => [
@@ -174,12 +178,12 @@ async function runCreate(
174
178
  }
175
179
 
176
180
  /**
177
- * You may only drop what `ls` shows, and that is the whole guard — stronger than a confirmation
181
+ * You may only delete what `list` shows, and that is the whole guard — stronger than a confirmation
178
182
  * flag, because it is the typo that is impossible rather than the keystroke that is tedious. An
179
183
  * external branch is a database carrying `createBranch`'s marker comment AND this database's own
180
184
  * prefix, so neither the shared database this session is connected to nor another app's clone on
181
185
  * the same server is in the set; an embedded one is a `pgdata-<name>` directory, so `pgdata` itself
182
- * is not either. `@ultimat3/db`'s own `X_BRANCH_EXISTS` fix line is `x db branch drop <name>` with
186
+ * is not either. `@ultimat3/db`'s own `X_BRANCH_EXISTS` fix line is `x db branch delete <name>` with
183
187
  * no flag on it, so a flag here would break a shipped instruction.
184
188
  *
185
189
  * The check is not made here, and that is the point: `false` from either drop means "there was no
@@ -187,7 +191,7 @@ async function runCreate(
187
191
  * earlier. A listing taken here and acted on below is two connections and a window wide enough to
188
192
  * hold a whole `create` — and the wiring layer is exactly where a guard must not live.
189
193
  */
190
- async function runDrop(services: DevServices, name: string): Promise<CommandResult> {
194
+ async function runDelete(services: DevServices, name: string): Promise<CommandResult> {
191
195
  try {
192
196
  const dropped =
193
197
  services.db.mode === 'embedded'
@@ -18,11 +18,11 @@ export const dbSpec: CommandSpec = {
18
18
  name: 'db',
19
19
  summary: 'gen, migrate, reset, seed, studio, branch, backfill',
20
20
  usage:
21
- 'x db gen "add publish_at" | migrate | reset | seed [<name>] [--tier reference|dev] [--dry-run] | studio | branch ls | branch create <name> | branch drop <name> | backfill [<name>|--all] [--write] [--force] | backfill --pending | backfill --list [--name n] [--status s] [--limit n]',
21
+ 'x db gen "add publish_at" | migrate | reset | seed [<name>] [--tier reference|dev] [--dry-run] | studio | branch list | branch create <name> | branch delete <name> | backfill [<name>|--all] [--write] [--force] | backfill --pending | backfill --list [--name n] [--status s] [--limit n]',
22
22
  requiresApp: true,
23
23
  subcommands: DB_SUBCOMMANDS,
24
24
  // Declared from the constant `runBranchCommand` validates against, never a second literal: it
25
- // is what lets the `errors` step resolve `x db branch ls` — a fix line three shipped errors
25
+ // is what lets the `errors` step resolve `x db branch list` — a fix line three shipped errors
26
26
  // hand out, which read `ls` as a branch name and cloned a database until 1.2.x.
27
27
  subcommandPositionals: { branch: BRANCH_SUBCOMMANDS },
28
28
  // Each flag whose summary begins `<subcommand>:` declares that scope, and the parser refuses
@@ -12,7 +12,8 @@ const seconds = (ms: number): number => Math.ceil(ms / 1000);
12
12
  /**
13
13
  * The `--set` pairs, or none when the app declares no budget (the chart's defaults are core's).
14
14
  * The chart's `drain.deadlineSeconds` is ONE budget for every role, and so is `drain.deadlineMs`:
15
- * the web role drains on it too. The margins stay the chart's.
15
+ * the web role drains on it too — the worker on `drain.workerDeadlineSeconds` instead, when the app
16
+ * declares `drain.workerDeadlineMs`. The margins stay the chart's.
16
17
  *
17
18
  * The grace is budgeted at no less than core's production default: the value read here is resolved
18
19
  * in THIS process's environment, and an `x deploy` run under `NODE_ENV=development` resolves the
@@ -24,6 +25,10 @@ export function helmDrainOverrides(drain: Partial<DrainConfig> | undefined): rea
24
25
  if (drain?.deadlineMs !== undefined) {
25
26
  out.push('--set', `drain.deadlineSeconds=${seconds(drain.deadlineMs)}`);
26
27
  }
28
+ // The worker role's own budget: the chart sizes only the worker's grace period from it.
29
+ if (drain?.workerDeadlineMs !== undefined) {
30
+ out.push('--set', `drain.workerDeadlineSeconds=${seconds(drain.workerDeadlineMs)}`);
31
+ }
27
32
  if (drain === undefined) return out;
28
33
  const grace = Math.max(drain.readinessGraceMs ?? 0, READINESS_GRACE_DEFAULT_MS);
29
34
  out.push('--set', `drain.readinessGraceSeconds=${seconds(grace)}`);
package/src/cmd-docs.ts CHANGED
@@ -144,7 +144,7 @@ export const docsCommand: CliCommand = {
144
144
  }
145
145
 
146
146
  const entries = await scanInstalledDocs(scope);
147
- // `x jobs ls --limit`'s reader, and its `command` parameter exists for exactly this second
147
+ // `x jobs list --limit`'s reader, and its `command` parameter exists for exactly this second
148
148
  // caller. A local `Number.parseInt` accepted `--limit 1e9` as 1 and answered with one match,
149
149
  // and fell silently through to the default for `abc`, `0` and `-3` — a bound other than the one
150
150
  // typed, from the same binary that refuses all four one command over.
@@ -3,12 +3,16 @@
3
3
 
4
4
  import type { CommandSpec } from './parse';
5
5
 
6
+ /**
7
+ * One verb each, the same across every `x` registry (#709): `list`, `show <one>`, `delete`. No
8
+ * `ls`/`rm` aliases — two spellings of one verb is the ambiguity axiom 1 forbids.
9
+ */
6
10
  export const JOBS_SUBCOMMANDS = [
7
- 'ls',
11
+ 'list',
8
12
  'show',
9
13
  'retry',
10
14
  'cancel',
11
- 'rm',
15
+ 'delete',
12
16
  'promote',
13
17
  'pause',
14
18
  'resume',
@@ -20,13 +24,13 @@ export const jobsSpec: CommandSpec = {
20
24
  // Never ENDING in "(planned)": that suffix is how `x help` and the planned-table tests tell a
21
25
  // planned COMMAND from a shipped one, and `x jobs` ships — only its `drain` does not.
22
26
  summary:
23
- 'list, show, retry, cancel, remove and promote jobs; pause and resume a queue (drain is planned)',
27
+ 'list, show, retry, cancel, delete and promote jobs; pause and resume a queue (drain is planned)',
24
28
  usage:
25
- 'x jobs [ls|show <id>|retry <id>|cancel <id>|rm <id>|promote <id>|pause <queue>|resume <queue>|drain] [--queue q] [--state s] [--name n] [--limit n] [--after cursor] [--from-step name] [--reason text] [--json]',
29
+ 'x jobs [list|show <id>|retry <id>|cancel <id>|delete <id>|promote <id>|pause <queue>|resume <queue>|drain] [--queue q] [--state s] [--name n] [--limit n] [--after cursor] [--from-step name] [--reason text] [--json]',
26
30
  requiresApp: true,
27
31
  subcommands: JOBS_SUBCOMMANDS,
28
32
  // The bare `x jobs` lists; it never retries, cancels or drains anything.
29
- defaultSubcommand: 'ls',
33
+ defaultSubcommand: 'list',
30
34
  flags: [
31
35
  { name: 'queue', type: 'string', summary: 'filter by queue name' },
32
36
  { name: 'state', type: 'string', summary: 'filter by job state' },
@@ -35,8 +39,8 @@ export const jobsSpec: CommandSpec = {
35
39
  {
36
40
  name: 'after',
37
41
  type: 'string',
38
- summary: 'ls: the next page — the cursor the previous page printed',
39
- subcommands: ['ls'],
42
+ summary: 'list: the next page — the cursor the previous page printed',
43
+ subcommands: ['list'],
40
44
  },
41
45
  // Each of these is read by ONE subcommand — `retryJob`, `cancelJob` — and says so in its own
42
46
  // summary. The scope is what makes the parser refuse it anywhere else instead
package/src/cmd-jobs.ts CHANGED
@@ -1,4 +1,4 @@
1
- // `x jobs ls|show|retry|cancel|rm|promote|pause|resume` — introspect and recover the job queue,
1
+ // `x jobs list|show|retry|cancel|delete|promote|pause|resume` — introspect and recover the job queue,
2
2
  // bound to `@ultimat3/jobs`'s own introspection so the CLI, `/_x` and MCP report identically;
3
3
  // `drain` is planned (`cmd-planned.ts`): Postgres is the one durable driver. CLI wiring only:
4
4
  // the driver-injected logic is `jobs-report.ts`, the `--json` shapes `jobs-json.ts`, the table
@@ -56,7 +56,7 @@ function requireIdPositional(ctx: CommandContext, sub: string, driver: JobDriver
56
56
  throw new MissingPositionalError({
57
57
  command: `jobs ${sub}`,
58
58
  positional: 'id',
59
- example: 'x jobs ls --json',
59
+ example: 'x jobs list --json',
60
60
  });
61
61
  }
62
62
  if (!JOB_ID.test(id)) throw new JobUnknownError({ id, driver: driver.name });
@@ -86,11 +86,11 @@ function refuseOversizedPage(limit: string | undefined): void {
86
86
  command: 'jobs',
87
87
  reason: `one page holds at most ${MAX_JOB_PAGE} jobs`,
88
88
  // The bound is the queue's own constant, screened like any value spliced into a command.
89
- fix: `x jobs ls --limit ${renderFixShellArg(String(MAX_JOB_PAGE), '200')} --json # then pass its data.nextCursor as --after while data.hasMore is true`,
89
+ fix: `x jobs list --limit ${renderFixShellArg(String(MAX_JOB_PAGE), '200')} --json # then pass its data.nextCursor as --after while data.hasMore is true`,
90
90
  });
91
91
  }
92
92
 
93
- async function runLs(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
93
+ async function runList(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
94
94
  const limit = flagString(ctx.args, 'limit');
95
95
  refuseOversizedPage(limit);
96
96
  const result = await listJobs(driver, {
@@ -208,8 +208,8 @@ async function runCancel(driver: JobDriver, ctx: CommandContext): Promise<Comman
208
208
  }
209
209
 
210
210
  /** Exit 0 means the row is gone. An id nobody queued is `X_JOB_UNKNOWN`; a running one refuses. */
211
- async function runRm(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
212
- const id = requireIdPositional(ctx, 'rm', driver);
211
+ async function runDelete(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
212
+ const id = requireIdPositional(ctx, 'delete', driver);
213
213
  const removed = await removeJob(driver, id);
214
214
  if (removed === undefined) throw new JobUnknownError({ id, driver: driver.name });
215
215
  return {
@@ -249,14 +249,14 @@ async function runPause(
249
249
  };
250
250
  }
251
251
 
252
- /** The subcommands that answer a `JobTrace`. `ls`, `rm`, `promote` and the rest read rows only. */
252
+ /** The subcommands that answer a `JobTrace`. `list`, `delete`, `promote` and the rest read rows only. */
253
253
  const TRACE_SUBCOMMANDS: ReadonlySet<string> = new Set(['show', 'retry', 'cancel']);
254
254
 
255
255
  export const jobsCommand: CliCommand = {
256
256
  spec: jobsSpec,
257
257
  async run(ctx: CommandContext): Promise<CommandResult> {
258
258
  const root = requireAppRoot('jobs', ctx.cwd).dir;
259
- const sub = ctx.args.subcommand ?? 'ls';
259
+ const sub = ctx.args.subcommand ?? 'list';
260
260
  // BEFORE `withJobDriver`, which boots the source queue: the answer needs no server, so a box
261
261
  // whose database is down gets it rather than the boot failure of a queue never to be used —
262
262
  // and nothing is leased. 24.x's `--to`/`--dry-run` are deleted, so the parser refuses them
@@ -272,10 +272,10 @@ export const jobsCommand: CliCommand = {
272
272
  if (sub === 'show') return runShow(driver, ctx);
273
273
  if (sub === 'retry') return runRetry(driver, ctx);
274
274
  if (sub === 'cancel') return runCancel(driver, ctx);
275
- if (sub === 'rm') return runRm(driver, ctx);
275
+ if (sub === 'delete') return runDelete(driver, ctx);
276
276
  if (sub === 'promote') return runPromote(driver, ctx);
277
277
  if (sub === 'pause' || sub === 'resume') return runPause(driver, ctx, sub);
278
- return runLs(driver, ctx);
278
+ return runList(driver, ctx);
279
279
  });
280
280
  },
281
281
  };
@@ -33,8 +33,8 @@ export const PLANNED_COMMANDS: readonly PlannedCommand[] = [
33
33
  {
34
34
  name: 'branch',
35
35
  summary: 'copy-on-write branch environments with a preview URL',
36
- usage: 'x branch [<name>|rm <name>] [--json]',
37
- fix: 'x db branch ls --json # the database half: ls, create <name>, drop <name>',
36
+ usage: 'x branch [<name>|delete <name>] [--json]',
37
+ fix: 'x db branch list --json # the database half: list, create <name>, delete <name>',
38
38
  },
39
39
  {
40
40
  name: 'status',
@@ -134,7 +134,7 @@ export const PLANNED_SUBCOMMANDS: readonly PlannedSubcommand[] = [
134
134
  {
135
135
  command: 'jobs',
136
136
  subcommand: 'drain',
137
- fix: 'x jobs ls --json # the queue as it stands; Postgres is the only durable driver, so there is nowhere to drain to',
137
+ fix: 'x jobs list --json # the queue as it stands; Postgres is the only durable driver, so there is nowhere to drain to',
138
138
  },
139
139
  ];
140
140
 
@@ -7,8 +7,8 @@ import type { CommandSpec } from './parse';
7
7
  export const actionsSpec: CommandSpec = {
8
8
  name: 'actions',
9
9
  summary: 'the action registry: input/output schema, policy, tags, MCP exposure',
10
- usage: 'x actions [list|describe <name>] [--json]',
11
- subcommands: ['list', 'describe'],
10
+ usage: 'x actions [list|show <name>] [--json]',
11
+ subcommands: ['list', 'show'],
12
12
  defaultSubcommand: 'list',
13
13
  requiresApp: true,
14
14
  };
@@ -16,8 +16,8 @@ export const actionsSpec: CommandSpec = {
16
16
  export const queriesSpec: CommandSpec = {
17
17
  name: 'queries',
18
18
  summary: 'the query registry: schema, policy, live, cache tags',
19
- usage: 'x queries [list|describe <name>] [--json]',
20
- subcommands: ['list', 'describe'],
19
+ usage: 'x queries [list|show <name>] [--json]',
20
+ subcommands: ['list', 'show'],
21
21
  defaultSubcommand: 'list',
22
22
  requiresApp: true,
23
23
  };
@@ -25,8 +25,8 @@ export const queriesSpec: CommandSpec = {
25
25
  export const entitiesSpec: CommandSpec = {
26
26
  name: 'entities',
27
27
  summary: 'the entity registry: columns, invariants, indexes, tenancy',
28
- usage: 'x entities [list|describe <name>] [--json]',
29
- subcommands: ['list', 'describe'],
28
+ usage: 'x entities [list|show <name>] [--json]',
29
+ subcommands: ['list', 'show'],
30
30
  defaultSubcommand: 'list',
31
31
  requiresApp: true,
32
32
  };
@@ -2,7 +2,7 @@
2
2
  // Replaces grepping a source tree, which is what an agent does when the framework has no answer
3
3
  // to "what actions/queries/entities exist". One table, one generic command body: the three
4
4
  // commands differ only in which registry they read, a row's columns, and (for a query only) the
5
- // one extra field `describe` surfaces that its descriptor does not already carry.
5
+ // one extra field `show` surfaces that its descriptor does not already carry.
6
6
 
7
7
  import type { ActionDescriptor, AnyAction } from '@ultimat3/action';
8
8
  import { describeActions, getAction, jsonSchemaOf } from '@ultimat3/action';
@@ -35,7 +35,7 @@ const detailLines = (payload: Readonly<Record<string, JsonValue>>): readonly str
35
35
  Object.entries(payload).map(([key, value]) => ` ${key}: ${formatValue(value)}`);
36
36
 
37
37
  /**
38
- * One config per registry: how `list` renders a row, and what `describe` adds beyond the
38
+ * One config per registry: how `list` renders a row, and what `show` adds beyond the
39
39
  * descriptor itself — only a query's input schema does (`QUERIES.extra`); `ActionDescriptor`
40
40
  * already carries `input`/`output`, and an entity has no invocation shape to surface.
41
41
  */
@@ -116,7 +116,7 @@ function listResult<D extends { readonly name: string }, Raw extends { describe(
116
116
  };
117
117
  }
118
118
 
119
- function describeResult<D extends { readonly name: string }, Raw extends { describe(): D }>(
119
+ function showResult<D extends { readonly name: string }, Raw extends { describe(): D }>(
120
120
  kind: RegistryKind<D, Raw>,
121
121
  ctx: CommandContext,
122
122
  findings: readonly Finding[],
@@ -126,7 +126,7 @@ function describeResult<D extends { readonly name: string }, Raw extends { descr
126
126
  // A positional, so `MissingPositionalError` — `--name on "x actions"` named a flag no registry
127
127
  // command declares, and reading it as one is a second refusal for the first one's advice.
128
128
  throw new MissingPositionalError({
129
- command: `${kind.kind} describe`,
129
+ command: `${kind.kind} show`,
130
130
  positional: 'name',
131
131
  example: `x ${kind.kind} list --json`,
132
132
  });
@@ -158,8 +158,8 @@ async function runRegistryCommand<
158
158
  >(kind: RegistryKind<D, Raw>, ctx: CommandContext): Promise<CommandResult> {
159
159
  const root = requireAppRoot(kind.kind, ctx.cwd).dir;
160
160
  const { findings } = await loadApp(root);
161
- return ctx.args.subcommand === 'describe'
162
- ? describeResult(kind, ctx, findings)
161
+ return ctx.args.subcommand === 'show'
162
+ ? showResult(kind, ctx, findings)
163
163
  : listResult(kind, findings);
164
164
  }
165
165
 
package/src/cmd-tasks.ts CHANGED
@@ -141,8 +141,8 @@ function requireHandle(ctx: CommandContext): TaskHandle {
141
141
  const suggestion = nearestName(name, known);
142
142
  throw new DeclarationUnknownError(
143
143
  suggestion === undefined
144
- ? { kind: 'tasks', singular: 'task', name, known, verb: 'show' }
145
- : { kind: 'tasks', singular: 'task', name, known, suggestion, verb: 'show' },
144
+ ? { kind: 'tasks', singular: 'task', name, known }
145
+ : { kind: 'tasks', singular: 'task', name, known, suggestion },
146
146
  );
147
147
  }
148
148
 
package/src/db-branch.ts CHANGED
@@ -27,7 +27,7 @@ import {
27
27
  * `reap` is deliberately absent: a nightly sweep is a `task` (`reapBranches` from `@ultimat3/db`),
28
28
  * and a CLI verb for it would be a second path to one job with a max-age nobody can default.
29
29
  */
30
- export const BRANCH_SUBCOMMANDS = ['ls', 'create', 'drop'] as const;
30
+ export const BRANCH_SUBCOMMANDS = ['list', 'create', 'delete'] as const;
31
31
 
32
32
  export type BranchSubcommand = (typeof BRANCH_SUBCOMMANDS)[number];
33
33
 
@@ -91,7 +91,7 @@ export function branchDatabaseName(source: string, branch: string): string {
91
91
  * `db.migrate` is aimed at a private database from a URL alone, with no connection to ask
92
92
  * `current_database()` — so "a branch of somebody" is the only question it can pose, and the
93
93
  * answer it wants for `analytics_branch_feat` is still "not the shared database". Anything holding
94
- * a client asks `branchNameIn` instead, which is the question `ls` and `drop` need.
94
+ * a client asks `branchNameIn` instead, which is the question `list` and `delete` need.
95
95
  */
96
96
  export const branchNameOf = (database: string): string | null =>
97
97
  /_branch_(.+)$/.exec(database)?.[1] ?? null;
@@ -112,7 +112,7 @@ export function branchNameIn(source: string, database: string): string | null {
112
112
  /**
113
113
  * The embedded peer: `branchPglite` copies `<dir>` to `<dir>-<name>`, so the branch name is the
114
114
  * suffix. `pgliteBranchDir` is the forward rule and this is its inverse — written once, because
115
- * `x db branch ls` and the MCP host's branch check must agree about what a branch directory is.
115
+ * `x db branch list` and the MCP host's branch check must agree about what a branch directory is.
116
116
  */
117
117
  export function pgliteBranchName(dir: string, source: string): string | null {
118
118
  return dir.startsWith(`${source}-`) ? dir.slice(source.length + 1) : null;
@@ -213,7 +213,7 @@ async function branchesOf(client: DbClient, source: string): Promise<readonly Br
213
213
  /**
214
214
  * Only databases carrying `createBranch`'s own marker comment, and only branches of the database
215
215
  * this session is connected to. A database this listing does not name is one `drop` may not touch,
216
- * which is what makes "you may only drop what `ls` shows" a guard rather than a courtesy — and a
216
+ * which is what makes "you may only delete what `list` shows" a guard rather than a courtesy — and a
217
217
  * row belonging to another app on the same server made that guard answer for a database it had
218
218
  * never seen.
219
219
  */
@@ -224,7 +224,7 @@ export async function listExternalBranches(client: DbClient): Promise<readonly B
224
224
  /**
225
225
  * Through `createBranch`, never a hand-written `CREATE DATABASE`: it validates the name, refuses a
226
226
  * database that already exists with `X_BRANCH_EXISTS`, and writes the marker comment that makes
227
- * the clone visible to `ls`. The CLI shelled out to `psql` until now and wrote no marker at all,
227
+ * the clone visible to `list`. The CLI shelled out to `psql` until now and wrote no marker at all,
228
228
  * so every branch it made was invisible to the only lister the framework has.
229
229
  */
230
230
  export async function createExternalBranch(client: DbClient, branch: string): Promise<BranchRow> {
@@ -232,7 +232,7 @@ export async function createExternalBranch(client: DbClient, branch: string): Pr
232
232
  const database = branchDatabaseName(source, branch);
233
233
  const info = await createBranch(database, { client, base: source });
234
234
  return {
235
- // The name `ls` will show for it, derived the way `ls` derives one — a create that reported a
235
+ // The name `list` will show for it, derived the way `list` derives one — a create that reported a
236
236
  // name the listing then spells differently is a `drop` the caller has to guess at.
237
237
  name: branchNameIn(source, database) ?? database,
238
238
  location: database,
package/src/db-finding.ts CHANGED
@@ -10,7 +10,7 @@ import { findingFrom, isUltimateErrorShape } from './output';
10
10
  /**
11
11
  * The engine names its own failures — `X_MIGRATION_CONFLICT` carries the ledger row that disagrees,
12
12
  * `X_MIGRATION_IRREVERSIBLE` carries the exact `--allow-destructive` line to rerun, and
13
- * `X_BRANCH_EXISTS` carries the `x db branch drop` that clears it — so those reach the caller
13
+ * `X_BRANCH_EXISTS` carries the `x db branch delete` that clears it — so those reach the caller
14
14
  * verbatim. `X_DB_GEN_FAILED` / `X_DB_MIGRATE_FAILED` / `X_DB_BRANCH_FAILED` are what is left: the
15
15
  * step failed for a reason no framework error claimed, and the raw message is all there is.
16
16
  */
@@ -4,7 +4,7 @@
4
4
  // login) ran twice. The worker is retired first — `stop()` claims nothing and aborts nothing — and
5
5
  // only then is the drain asked to release the port, the lock and the embedded database.
6
6
 
7
- import { drain, logger, renderThrowable } from '@ultimat3/core';
7
+ import { drain, logger, markRetiring, renderThrowable } from '@ultimat3/core';
8
8
  import type { Worker } from '@ultimat3/jobs';
9
9
  import { msg } from './messages';
10
10
  import { writeErrorLine } from './write-line';
@@ -32,6 +32,8 @@ export async function retireThenDrain(
32
32
  worker: RetiringWorker | null,
33
33
  deps: RetireDeps = DEFAULT_DEPS,
34
34
  ): Promise<void> {
35
+ // The same retire the production SIGUSR2 is (`serve-retire.ts`), so the app reads it the same way.
36
+ markRetiring();
35
37
  try {
36
38
  if (worker !== null) {
37
39
  const { inFlight } = await worker.stats();
@@ -30,14 +30,12 @@ export interface SurfaceDocument {
30
30
  }
31
31
 
32
32
  /**
33
- * Every surface that renders a document, with the CSS one would carry. `api/` is excluded because
34
- * it emits no document at all — a surface with nothing to style is not a surface missing its
35
- * tokens. Read from render's own registry, filled when the CLI loaded the app: a second walk of
33
+ * Every surface that renders a document, with the CSS one would carry — a route lives on `site/`
34
+ * or `app/` only, so `api/` (no document) never appears. Read from render's own registry, filled when the CLI loaded the app: a second walk of
36
35
  * the app's stylesheets here would be a second answer to "what does this document contain".
37
36
  */
38
37
  export function documentSurfaces(): readonly SurfaceDocument[] {
39
38
  const surfaces = new Set(routeEntries().map((entry) => entry.surface));
40
- surfaces.delete('api');
41
39
  return [...surfaces]
42
40
  .sort()
43
41
  .map((surface) => ({ surface, css: stylesFor(surface) }) satisfies SurfaceDocument);
@@ -32,7 +32,7 @@ export const BANNED_PHRASES: readonly RegExp[] = [
32
32
  /**
33
33
  * What makes a fix actionable as written: the `x` CLI, a tool the machine already has, a call the
34
34
  * reader can paste, or a file they can open. A banned phrase is only a failure without one of
35
- * these — "check the gateway, then: x actions describe posts.publish --json" names the observation
35
+ * these — "check the gateway, then: x actions show posts.publish --json" names the observation
36
36
  * *and* the command, which is the shape the contract wants.
37
37
  */
38
38
  export const COMMAND_TOKENS: readonly RegExp[] = [
package/src/errors.ts CHANGED
@@ -248,7 +248,7 @@ export class ErrorCodeUnknownError extends UltimateError {
248
248
  }
249
249
 
250
250
  /**
251
- * `x actions|queries|entities describe <name>` named a declaration the registries do not hold —
251
+ * `x actions|queries|entities|tasks show <name>` named a declaration the registries do not hold —
252
252
  * a typo, or a module that never imported. `known` is the count, not the list: a 200-action app
253
253
  * would bury the fix line under names nobody asked for, and `list` is one command away.
254
254
  */
@@ -259,7 +259,7 @@ export class DeclarationUnknownError extends UltimateError {
259
259
  name: string;
260
260
  known: readonly string[];
261
261
  suggestion?: string;
262
- /** The subcommand that takes one name. `describe` for the registries, `show` for `x tasks`. */
262
+ /** The subcommand that takes one name: `show` (#709), or `explain` for `x policy` — the "why". */
263
263
  verb?: string;
264
264
  }) {
265
265
  super({
@@ -268,7 +268,7 @@ export class DeclarationUnknownError extends UltimateError {
268
268
  fix:
269
269
  input.suggestion === undefined
270
270
  ? `x ${input.kind} list --json`
271
- : `x ${input.kind} ${input.verb ?? 'describe'} ${input.suggestion}`,
271
+ : `x ${input.kind} ${input.verb ?? 'show'} ${input.suggestion}`,
272
272
  });
273
273
  }
274
274
  }
@@ -279,7 +279,7 @@ export class JobUnknownError extends UltimateError {
279
279
  super({
280
280
  code: 'X_JOB_UNKNOWN',
281
281
  cause: `the "${input.driver}" queue holds no job with id "${input.id}"`,
282
- fix: 'x jobs ls --json',
282
+ fix: 'x jobs list --json',
283
283
  });
284
284
  }
285
285
  }
@@ -27,7 +27,7 @@ import { GLOBAL_FLAGS } from './parse';
27
27
  // slot where the reader has nothing to substitute, so `x db branch <name>` — two shipped fix lines
28
28
  // in `@ultimat3/mcp` — is `X_CLI_UNKNOWN_COMMAND` when run and resolved clean while a placeholder
29
29
  // was invisible to the reader. Second and fourth slots are open positionals (`x new my-app`,
30
- // `x db branch drop <name>`), where a placeholder is exactly right.
30
+ // `x db branch delete <name>`), where a placeholder is exactly right.
31
31
  // A `:` is part of a word only when a letter follows it, which is what separates the shipped
32
32
  // positional `admin:page` from prose that ends a citation with a colon (`x verify: the gate`).
33
33
  // Read without it, `x g admin:page` cites `x g admin` — a positional the CLI does not ship —
@@ -1,4 +1,4 @@
1
- // Pure, driver-injected job operations behind `x jobs`: flag parsing, plus ls / show / retry. No
1
+ // Pure, driver-injected job operations behind `x jobs`: flag parsing, plus list / show / retry. No
2
2
  // CLI parsing, no process I/O, no rendering — a test drives every path with `memoryJobDriver()`
3
3
  // alone. The `--json` shapes are `jobs-json.ts`, the table `jobs-table.ts`.
4
4
 
@@ -61,7 +61,7 @@ export function parseLimitFlag(value: string | undefined, command = 'jobs'): num
61
61
  return limit;
62
62
  }
63
63
 
64
- // ── ls ────────────────────────────────────────────────────────────────────
64
+ // ── list ──────────────────────────────────────────────────────────────────
65
65
 
66
66
  export interface JobsListFilter {
67
67
  readonly queue?: string | undefined;
@@ -105,7 +105,7 @@ async function jobPage(driver: JobDriver, filter: JobFilter): Promise<Page<JobRe
105
105
  * `--state ready` filter (or the default 100-row cap) pushes out of view is the exact failure
106
106
  * mode this command exists to prevent.
107
107
  *
108
- * Backfills are read `running` only. `x jobs ls` is a LIVE view of the queue — a pass that
108
+ * Backfills are read `running` only. `x jobs list` is a LIVE view of the queue — a pass that
109
109
  * finished last week is history, and `x db backfill --list` is where that question is asked and
110
110
  * answered with the whole ledger. A driver with no ledger answers `[]` rather than throwing, so
111
111
  * the queue view never fails over a fact nobody asked about.
package/src/jobs-table.ts CHANGED
@@ -1,4 +1,4 @@
1
- // The `x jobs ls` terminal table, and nothing else. Split out of `jobs-report.ts` so that
1
+ // The `x jobs list` terminal table, and nothing else. Split out of `jobs-report.ts` so that
2
2
  // deciding how a value LOOKS lives apart from deciding what the queue DOES: a rendering rule
3
3
  // (column order, padding, the run-at unit) is reviewed here, once, with no queue logic around it.
4
4
 
@@ -183,7 +183,6 @@ export async function liveRouteGaps(
183
183
  const reported = new Set<string>();
184
184
  const gaps: LiveRouteGap[] = [];
185
185
  for (const entry of entries) {
186
- if (entry.surface === 'api') continue;
187
186
  const never = entry.config.hydrate === 'never';
188
187
  for (const read of await browserOnlyReads(root, entry.file)) {
189
188
  if (reported.has(read.at) || (!never && inBrowser.has(read.at))) continue;
@@ -56,7 +56,7 @@ const isProduction = (env: Env): boolean => {
56
56
  /**
57
57
  * `x db branch create <name>` names an external clone `<source>_branch_<name>`. Both readings come
58
58
  * from `db-branch.ts` — the module that also WRITES those names — because a target that disagrees
59
- * with `x db branch ls` about what a branch is would let `db.migrate` run against a shared
59
+ * with `x db branch list` about what a branch is would let `db.migrate` run against a shared
60
60
  * database on the strength of a naming rule one of the two had drifted away from.
61
61
  */
62
62
  function postgresBranch(url: string): string | null {
package/src/mcp-errors.ts CHANGED
@@ -60,7 +60,7 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
60
60
  X_APP_PACKAGE_INVALID: 'bun pm pkg set name=my-app version=0.1.0',
61
61
  X_ERROR_CODE_UNKNOWN: 'x errors list --json',
62
62
  X_DECLARATION_UNKNOWN: 'x actions list --json',
63
- X_JOB_UNKNOWN: 'x jobs ls --json',
63
+ X_JOB_UNKNOWN: 'x jobs list --json',
64
64
  X_FIX_TARGET_UNKNOWN: 'x fix boundary apps/web/site/page.tsx --json',
65
65
  X_ERROR_FIX_INVALID: 'x verify --json # the finding names the file, the line and the fix text',
66
66
  X_ERROR_FIX_PATH_MISSING:
@@ -213,7 +213,7 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
213
213
  X_QUERY_SUBSCRIBES_UNKNOWN:
214
214
  'x db gen "retry after fixing subscribes" --json # first edit subscribes: on the query the cause names',
215
215
  X_DB_MIGRATE_FAILED: 'x doctor --json # cause carries the Postgres error verbatim',
216
- X_DB_BRANCH_FAILED: 'x db branch ls --json',
216
+ X_DB_BRANCH_FAILED: 'x db branch list --json',
217
217
  X_DB_STUDIO_FAILED: 'x doctor --json',
218
218
  // Runnable first, the narrowing behind a `#`, exactly as X_CLI_UNKNOWN_COMMAND above: naming
219
219
  // the tier IS the consent, and which seed to consent to is the one thing this table cannot
package/src/messages.ts CHANGED
@@ -46,7 +46,7 @@ const CATALOG = {
46
46
  'cli.db.branch.failed': 'branch command failed',
47
47
  'cli.db.branch.listed': '{count} branch(es) of this database',
48
48
  'cli.db.branch.none': 'this database has no branch',
49
- /** The empty cell in an `x db branch ls` column — a value, not a column key. */
49
+ /** The empty cell in an `x db branch list` column — a value, not a column key. */
50
50
  'cli.db.branch.unknown': '-',
51
51
  'cli.db.gen.failed': 'migration not generated',
52
52
  'cli.db.gen.unchanged': 'entities and migrations agree — nothing to generate',
@@ -149,7 +149,7 @@ const CATALOG = {
149
149
  'cli.jobs.depth':
150
150
  '{ready} ready · {running} running · {delayed} delayed · {dead} dead across {queues} queue(s)',
151
151
  'cli.jobs.listed': '{count} job(s)',
152
- 'cli.jobs.nextPage': 'more — next page: x jobs ls --after {cursor}',
152
+ 'cli.jobs.nextPage': 'more — next page: x jobs list --after {cursor}',
153
153
  'cli.jobs.noError': 'no error recorded',
154
154
  'cli.jobs.paused': 'queue {queue} paused — no worker claims from it; enqueues still land',
155
155
  'cli.jobs.pausedQueues': 'paused queue(s): {queues}',
package/src/parse.ts CHANGED
@@ -44,7 +44,7 @@ export interface CommandSpec {
44
44
  * than a misspelt one. Declared per command, never inferred, because only some commands can say
45
45
  * it truthfully: `x errors X_PERMISSION_UNKNOWN` can only be a code, and `x jobs 4f2a` is
46
46
  * genuinely ambiguous with `show`, so an unconditional fallback would turn `x jobs <id>` into a
47
- * silent `x jobs ls` that ignores the id.
47
+ * silent `x jobs list` that ignores the id.
48
48
  *
49
49
  * `x errors X_PERMISSION_UNKNOWN --json` answered `X_CLI_UNKNOWN_COMMAND … fix: x help`, and
50
50
  * `x help` prints `errors an X_* code, explained` — which reads as exactly the form that was
@@ -304,6 +304,27 @@ function splitInline(raw: string): [string, string | undefined] {
304
304
  return [raw.slice(0, eq), raw.slice(eq + 1)];
305
305
  }
306
306
 
307
+ /**
308
+ * The verbs 26.0.0 retired (#709): one verb each across every `x` registry — `list`, `show <one>`,
309
+ * `delete` — and no alias, because two spellings of one verb is the ambiguity axiom 1 forbids. A
310
+ * retired verb is REFUSED, never accepted; this table only lets the refusal name its replacement.
311
+ */
312
+ export const RETIRED_VERBS = Object.freeze<Record<string, string>>({
313
+ ls: 'list',
314
+ rm: 'delete',
315
+ describe: 'show',
316
+ drop: 'delete',
317
+ });
318
+
319
+ /** The verb that replaced `word`, when `word` was retired and its replacement is in `allowed`. */
320
+ export function retiredVerbReplacement(
321
+ word: string,
322
+ allowed: readonly string[],
323
+ ): string | undefined {
324
+ const replacement = Object.hasOwn(RETIRED_VERBS, word) ? RETIRED_VERBS[word] : undefined;
325
+ return replacement !== undefined && allowed.includes(replacement) ? replacement : undefined;
326
+ }
327
+
307
328
  /** Which subcommand ran, and whether the caller's first positional is what named it. */
308
329
  interface ResolvedSubcommand {
309
330
  readonly name: string | undefined;
@@ -323,7 +344,7 @@ function readSubcommand(spec: CommandSpec, positionals: readonly string[]): Reso
323
344
  throw new MissingSubcommandError({ command: spec.name, known: allowed });
324
345
  }
325
346
  if (allowed.includes(token)) return { name: token, consumed: true };
326
- const suggestion = nearestName(token, allowed);
347
+ const suggestion = retiredVerbReplacement(token, allowed) ?? nearestName(token, allowed);
327
348
  // The declared fallback, and only past the near-miss guard: a word within `nearestName`'s edit
328
349
  // budget of a real subcommand is a typo, and reading it as the default subcommand's argument
329
350
  // would answer a question nobody asked.
@@ -73,7 +73,7 @@ const embeddedRefusal = (dev: boolean): BadFlagError | ConfigInvalidError =>
73
73
  * Both readers of this list are catalog readers. `PgReplicationStream` keeps a change only when
74
74
  * `#entities.has(relation.name)`, and a pgoutput Relation message names the table; `warnPartialIdentity`
75
75
  * matches the same list against `pg_class.relname`. An entity NAME is the framework's own registry
76
- * key — what a cache tag, a policy and `x entities describe` are keyed by — and `entity('user',
76
+ * key — what a cache tag, a policy and `x entities show` are keyed by — and `entity('user',
77
77
  * { table: 'users' })` makes the two different strings. Passing the name meant a renamed table
78
78
  * matched nothing on either side: every change SKIPPED, and a replica-identity warning that could
79
79
  * never fire. Latent wherever the two agree, which is every entity in `examples/dummy`.
@@ -93,7 +93,7 @@ function reportUnserved(served: readonly string[], registered: readonly string[]
93
93
  logger.warn('jobs.worker.queue-unserved', {
94
94
  queues,
95
95
  served,
96
- fix: `x jobs ls --json — every queue listed with no worker needs one: WORKER_QUEUES=<queue>,<queue> on another worker Deployment, or add it to this one's`,
96
+ fix: `x jobs list --json — every queue listed with no worker needs one: WORKER_QUEUES=<queue>,<queue> on another worker Deployment, or add it to this one's`,
97
97
  });
98
98
  }
99
99
 
package/src/serve-boot.ts CHANGED
@@ -54,13 +54,19 @@ const LISTENING_ROLES: readonly Role[] = ['web', 'sync'];
54
54
  * — it only went on claiming jobs for those seconds, then aborted them at the drain (s1-con #7).
55
55
  *
56
56
  * The budget (`deadlineMs`) is every role's: it is the time a worker's running job has to finish
57
- * on a deploy, so it is the one key here a non-listening role needs MOST.
57
+ * on a deploy, so it is the one key here a non-listening role needs MOST. The worker's alone is
58
+ * `workerDeadlineMs` when declared — the hour `deadlineMs` is capped at is too short for a job
59
+ * that must not run twice, and a web role has no business waiting two hours on a request.
58
60
  */
59
61
  export function lifecycleForRole(
60
62
  role: Role,
61
63
  drain: Partial<DrainConfig> | undefined,
62
64
  ): LifecycleOptions {
63
- const budget = drain?.deadlineMs === undefined ? {} : { deadlineMs: drain.deadlineMs };
65
+ const declared =
66
+ role === 'worker' && drain?.workerDeadlineMs !== undefined
67
+ ? drain.workerDeadlineMs
68
+ : drain?.deadlineMs;
69
+ const budget = declared === undefined ? {} : { deadlineMs: declared };
64
70
  if (!LISTENING_ROLES.includes(role)) return { readinessGraceMs: 0, ...budget };
65
71
  return drain?.readinessGraceMs === undefined
66
72
  ? budget
@@ -9,7 +9,11 @@ export async function loadDrainConfig(root: string): Promise<Partial<DrainConfig
9
9
  const drain = (await loadAppConfig(root))?.drain;
10
10
  return drain === undefined
11
11
  ? undefined
12
- : { readinessGraceMs: drain.readinessGraceMs, deadlineMs: drain.deadlineMs };
12
+ : {
13
+ readinessGraceMs: drain.readinessGraceMs,
14
+ deadlineMs: drain.deadlineMs,
15
+ workerDeadlineMs: drain.workerDeadlineMs,
16
+ };
13
17
  }
14
18
 
15
19
  /** `health.readiness`, or `undefined` for a root with no config file (core keeps its default). */
@@ -1,9 +1,9 @@
1
1
  // `ROLE=worker`'s retire, on SIGUSR2 (issue #669): stop claiming, finish every held job however
2
2
  // long it takes, abort nothing — then the ordinary drain, so the process exits 0. It is what a
3
3
  // Kubernetes `preStop` sends before the SIGTERM a rollout ends in, for a job whose side effect may
4
- // happen at most once and may outlast `drain.deadlineMs`. Framework-owned: no port, no app code.
4
+ // happen at most once and may outlast the drain budget. Framework-owned: no port, no app code.
5
5
 
6
- import { drain, logger, renderThrowable } from '@ultimat3/core';
6
+ import { drain, logger, markRetiring, renderThrowable } from '@ultimat3/core';
7
7
  import type { Worker } from '@ultimat3/jobs';
8
8
 
9
9
  /** The signal a worker retires on. Free in Bun and Node (SIGUSR1 is the inspector's). */
@@ -43,7 +43,8 @@ const NOTHING: WorkerRetire = Object.freeze({ adopt: () => undefined, disarm: ()
43
43
  * Exits rather than holds, decided 2026-10-07: the `preStop` has no channel back but PID 1 itself,
44
44
  * so "retired" has to be observable as "PID 1 is gone" — and a pod that has stopped claiming serves
45
45
  * nothing it could keep holding for. A SIGTERM landing mid-retire is the ordinary drain: it binds
46
- * the retire to `drain.deadlineMs` and cuts off at the margin (`packages/jobs/src/worker.ts`).
46
+ * the retire to the worker's budget — `drain.workerDeadlineMs`, else `drain.deadlineMs` — and cuts
47
+ * off at the margin (`packages/jobs/src/worker.ts`). `isRetiring()` is true from the signal on.
47
48
  */
48
49
  export function armWorkerRetire(options: WorkerRetireOptions = {}): WorkerRetire {
49
50
  if ((options.platform ?? process.platform) === 'win32') return NOTHING;
@@ -84,6 +85,9 @@ export function armWorkerRetire(options: WorkerRetireOptions = {}): WorkerRetire
84
85
  const listener = (): void => {
85
86
  if (retiring) return;
86
87
  retiring = true;
88
+ // At the signal, before the worker is awaited or stopped: the app's `isRetiring()` is how its
89
+ // own code (a heartbeat, a status row) sees a retire that `isDraining()` only shows at the end.
90
+ markRetiring();
87
91
  // Never rejects: `retire` catches its own failure and always reaches the drain.
88
92
  void retire();
89
93
  };
package/src/site-seo.ts CHANGED
@@ -110,7 +110,7 @@ type Stamp = (file: string) => { readonly lastmod?: string };
110
110
  /**
111
111
  * The app's `seo.sitemap.extra` paths as records, each resolved to the route that answers it — the
112
112
  * most specific match, the router's own tie-break. REFUSED, never skipped, when it names no route,
113
- * an `api/` route, a gated one or a `site/` page (listed already): a sitemap missing a page its
113
+ * a gated one or a `site/` page (listed already): a sitemap missing a page its
114
114
  * author listed is the silent drop, and a gated page in it is a URL a crawler cannot open.
115
115
  */
116
116
  function extraRoutes(extra: readonly string[], stamp: Stamp): readonly RouteRecord[] {
@@ -121,9 +121,6 @@ function extraRoutes(extra: readonly string[], stamp: Stamp): readonly RouteReco
121
121
  .sort((a, b) => b.pattern.specificity - a.pattern.specificity)[0];
122
122
  if (entry === undefined)
123
123
  throw new SitemapExtraInvalidError(path, 'no registered route answers');
124
- if (entry.surface === 'api') {
125
- throw new SitemapExtraInvalidError(path, `is the api/ route ${entry.file}, not a page`);
126
- }
127
124
  if (entry.surface === 'site') {
128
125
  throw new SitemapExtraInvalidError(
129
126
  path,
@@ -40,7 +40,7 @@ stop; that answer is worth more than a plan built on a shape the app cannot hold
40
40
  |---|---|
41
41
  | \`apps/web/site/<path>/page.tsx\` | public, SEO-critical, 0kb JS |
42
42
  | \`apps/web/app/<slice>/\` | the feature slice: entity, repo, policy, actions, queries, jobs, UI |
43
- | \`apps/web/api/<name>/route.ts\` | HTTP surface for actions |
43
+ | \`apps/web/api/index.ts\` | the one \`defineApi()\` collecting every action and query — no route file lives under \`api/\` |
44
44
  | \`apps/web/shared/\` | tokens, primitives, the actor type — a leaf, imports nothing of yours |
45
45
  | \`packages/db/\` | schema, migrations, seed |
46
46
  | \`packages/ui/\` | components with no feature knowledge |
@@ -79,7 +79,7 @@ feature slice. Nothing else. A change outside that set is a collision — report
79
79
  | Money | \`{ minor, currency }\` — integer minor units and an ISO code, both, always. Never a float |
80
80
  | Time | store UTC; a formatted date always names an explicit IANA time zone |
81
81
 
82
- Inspect before you change: \`x entities list\`, \`x entities describe <name>\`, both with \`--json\`.
82
+ Inspect before you change: \`x entities list\`, \`x entities show <name>\`, both with \`--json\`.
83
83
 
84
84
  Checks: \`bun test <path>/entity.test.ts\`, \`bunx biome check --write <paths>\`, and \`bun run typecheck\`
85
85
  once when you are otherwise done. Never \`x verify\` — that belongs to whoever coordinates you.
@@ -105,7 +105,7 @@ renders. A change outside that set is a collision — report it, do not make it.
105
105
  | Tasks name a time zone | a cron schedule with an ambient zone is a different time twice a year |
106
106
  | No \`any\` | \`unknown\` plus a schema parse |
107
107
 
108
- Inspect before you change: \`x actions describe <name>\`, \`x queries describe <name>\`, \`x jobs ls\`,
108
+ Inspect before you change: \`x actions show <name>\`, \`x queries show <name>\`, \`x jobs list\`,
109
109
  \`x tasks list\`, \`x policy explain <subject>\` — every one takes \`--json\`.
110
110
 
111
111
  Checks: \`bun test <path>/<file>.test.ts\`, \`bunx biome check --write <paths>\`, and \`bun run typecheck\`
@@ -126,7 +126,7 @@ calls. A change outside that set is a collision — report it, do not make it.
126
126
  |---|---|
127
127
  | \`site/\` is 0kb JS | and may not import from \`app/\`. One interactive control on a static page is an island: \`x g island <name> --at <dir>\` |
128
128
  | \`shared/\` is a leaf | tokens, primitives, the actor type. It imports nothing of yours |
129
- | The directory is the URL | \`page.tsx\` under \`site/\`/\`app/\`, \`route.ts\` under \`api/\`. The filename is never the path |
129
+ | The directory is the URL | \`page.tsx\` under \`site/\`/\`app/\`; \`api/\` holds no route file, only \`defineApi()\`'s actions and queries. The filename is never the path |
130
130
  | Every string through \`t()\` | a literal in a component is a string no locale can ever translate |
131
131
  | Semantic tokens only | never a raw hex, in a component or a stylesheet |
132
132
  | Dates name a zone | explicit IANA time zone at every call site, no ambient default |
@@ -66,12 +66,12 @@ SQL into \`packages/db/migrations/\`.
66
66
  |---|---|---|
67
67
  | \`apps/web/site/\` | 0kb JS, may not import \`apps/web/app/\` | the static path pays the app's bundle |
68
68
  | \`apps/web/app/\` | authed, streaming, hydrated | — |
69
- | \`apps/web/api/\` | actions only, \`route.ts\` | a second HTTP surface |
69
+ | \`apps/web/api/\` | actions and queries only, collected by \`defineApi()\` — no route file | a second HTTP surface |
70
70
  | \`apps/web/shared/\` | a leaf: imports nothing of yours | an import cycle across surfaces |
71
71
  | \`repo.ts\` | the only file that touches the database | authz bypassed by a raw read |
72
72
  | routes | call actions and queries, never a repo | policy skipped |
73
73
 
74
- Route files: \`page.tsx\` under \`site/\`/\`app/\`, \`route.ts\` under \`api/\`. **The directory is the URL** —
74
+ Route files: \`page.tsx\` under \`site/\`/\`app/\`, and none under \`api/\`. **The directory is the URL** —
75
75
  the filename never is. One interactive control on a 0kb page is an island: \`x g island <name> --at <dir>\`.
76
76
 
77
77
  ## 4. Gate it
@@ -32,7 +32,7 @@ const settings = (): string => `{
32
32
  "Bash(x actions:*)",
33
33
  "Bash(x queries:*)",
34
34
  "Bash(x entities:*)",
35
- "Bash(x jobs ls:*)",
35
+ "Bash(x jobs list:*)",
36
36
  "Bash(x jobs show:*)",
37
37
  "Bash(x tasks:*)",
38
38
  "Bash(x policy:*)",
@@ -195,14 +195,18 @@ nobody bound. Derived here rather than stated twice in values.yaml, where the tw
195
195
  {{/*
196
196
  A role's terminationGracePeriodSeconds, in the order the kubelet's budget is spent: the preStop
197
197
  sleep (web and sync, 1.30+), the worker's opt-in retire (roles.worker.retireSeconds), the
198
- readiness grace (web and sync — every other role drains with none), the drain budget (app.config.ts drain.deadlineMs, every role) and the teardown margin.
198
+ readiness grace (web and sync — every other role drains with none), the drain budget (app.config.ts drain.deadlineMs, every role;
199
+ the worker's is drain.workerDeadlineSeconds instead when > 0, from drain.workerDeadlineMs) and the teardown margin.
199
200
  Defaults: 45s web/sync (40s below 1.30), 35s the rest. x deploy --method helm sets
200
- drain.deadlineSeconds and drain.readinessGraceSeconds from app.config.ts.
201
+ drain.deadlineSeconds, drain.workerDeadlineSeconds and drain.readinessGraceSeconds from app.config.ts.
201
202
  */}}
202
203
  {{- define "${app.kebab}.terminationGracePeriodSeconds" -}}
203
204
  {{- $drain := .root.Values.drain -}}
204
205
  {{- $total := add (int $drain.deadlineSeconds) (int $drain.teardownMarginSeconds) -}}
205
206
  {{- if eq .role "worker" -}}
207
+ {{- if gt (int $drain.workerDeadlineSeconds) 0 -}}
208
+ {{- $total = add (int $drain.workerDeadlineSeconds) (int $drain.teardownMarginSeconds) -}}
209
+ {{- end -}}
206
210
  {{- $total = add $total (int .cfg.retireSeconds) -}}
207
211
  {{- end -}}
208
212
  {{- if .cfg.port -}}
@@ -99,11 +99,14 @@ securityContext:
99
99
  # readinessGraceSeconds app.config.ts drain.readinessGraceMs / 1000 (web and sync only)
100
100
  # deadlineSeconds app.config.ts drain.deadlineMs / 1000 — x deploy --method helm sets
101
101
  # both from app.config.ts, so a raised budget raises the grace with it
102
+ # workerDeadlineSeconds app.config.ts drain.workerDeadlineMs / 1000 — the worker's budget in place
103
+ # of deadlineSeconds, up to a day; 0 means the worker uses deadlineSeconds
102
104
  # teardownMarginSeconds the release after the drain, the telemetry flush, the pool close
103
105
  drain:
104
106
  preStopSleepSeconds: 5
105
107
  readinessGraceSeconds: 5
106
108
  deadlineSeconds: 25
109
+ workerDeadlineSeconds: 0
107
110
  teardownMarginSeconds: 10
108
111
 
109
112
  # How long a new pod must stay Ready before a rollout counts it and stops an old one.
@@ -166,7 +169,7 @@ roles:
166
169
  replicas: 2
167
170
  # The retire, OFF at 0. Set to the longest a held job may run, in seconds, and the worker's
168
171
  # preStop sends its PID 1 SIGUSR2: stop claiming, finish every held job, abort nothing, exit 0
169
- # — for a job whose side effect must happen at most once and may outlast drain.deadlineSeconds.
172
+ # — for a job whose side effect must happen at most once and may outlast its drain budget.
170
173
  # Added to this role's terminationGracePeriodSeconds, which the kubelet counts the preStop against.
171
174
  retireSeconds: 0
172
175
  resources: