@jr2/cli 0.4.1 → 0.5.1

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.
@@ -1,7 +1,7 @@
1
- // `jr2 down [--all] [-n <ns>] [--context <ctx>]` (ADR-0019): remove the instance from the cluster —
2
- // its namespace and everything `jr2 up` converged into it. ALWAYS confirms (deleting a namespace
3
- // takes the runs, the store PVC, the Instance Harness's conversations PVC (ADR-0031), and every
4
- // live Sandbox with it). `--all` also uninstalls the
1
+ // `jr2 down [--all] [--yes] [-n <ns>] [--context <ctx>]` (ADR-0019): remove the instance from the
2
+ // cluster — its namespace and everything `jr2 up` converged into it. Confirms unless `--yes`
3
+ // (deleting a namespace takes the runs, the store PVC, the Instance Harness's conversations PVC
4
+ // (ADR-0031), and every live Sandbox with it). `--all` also uninstalls the
5
5
  // per-cluster operator — only sane when this was the cluster's last instance, which is the
6
6
  // caller's judgment, not derivable here.
7
7
  //
@@ -22,27 +22,17 @@
22
22
  // bounded by the node's disk; on kind they live inside the node container and go with the cluster.
23
23
 
24
24
  import { basename } from "node:path";
25
- import { parseArgs } from "node:util";
26
- import { loadConfig } from "@jr2/orchestrator";
25
+ import { loadConfig } from "@jr2/orchestrator/internal";
27
26
  import { pnpmDockerBuild } from "../build.ts";
28
27
  import { KIT_VERSION, LABEL_INSTANCE, operatorManifest } from "../deploy.ts";
29
28
  import { resolveRoot } from "../instance.ts";
30
29
  import { kubectlAdmin } from "../kube.ts";
31
30
  import { activity, confirmOrBail, type Io } from "../output.ts";
32
31
  import { sweepImages } from "../sweep.ts";
32
+ import { parseCommand } from "../command-table.ts";
33
33
 
34
34
  export async function down(args: string[], io: Io): Promise<number> {
35
- const { values } = parseArgs({
36
- args,
37
- allowPositionals: true,
38
- strict: false,
39
- options: {
40
- all: { type: "boolean" },
41
- yes: { type: "boolean" },
42
- namespace: { type: "string", short: "n" },
43
- context: { type: "string" },
44
- },
45
- });
35
+ const { values } = parseCommand("down", args);
46
36
 
47
37
  const root = resolveRoot(io.cwd);
48
38
  const config = (await loadConfig(root)) ?? {};
@@ -15,24 +15,15 @@
15
15
  // resolves no instance folder: "disk is full now" has to work from anywhere, and a kube context is
16
16
  // the only address this command needs.
17
17
 
18
- import { parseArgs } from "node:util";
19
18
  import { pnpmDockerBuild } from "../build.ts";
20
19
  import { kubectlAdmin } from "../kube.ts";
21
20
  import { activity, type Io } from "../output.ts";
22
21
  import { DEFAULT_REPO_TTL, parseRepoTtl, sweepRepos } from "../repo-sweep.ts";
23
22
  import { sweepImages } from "../sweep.ts";
23
+ import { parseCommand } from "../command-table.ts";
24
24
 
25
25
  export async function gc(args: string[], io: Io): Promise<number> {
26
- const { values } = parseArgs({
27
- args,
28
- allowPositionals: true,
29
- strict: false,
30
- options: {
31
- "dry-run": { type: "boolean" },
32
- "repo-ttl": { type: "string" },
33
- context: { type: "string" },
34
- },
35
- });
26
+ const { values } = parseCommand("gc", args);
36
27
  const ttl = (values["repo-ttl"] as string | undefined) ?? DEFAULT_REPO_TTL;
37
28
  // Refused before anything is read: a TTL that does not parse must not become "sweep now".
38
29
  parseRepoTtl(ttl);
@@ -4,7 +4,8 @@
4
4
  // does `jr2 up`, which refuses to converge a folder the compiler rejects — ADR-0050), a
5
5
  // `.gitignore` for the runtime `.jr2/`, ONE starter workflow (`ping`) that runs end-to-end with no
6
6
  // Workspace/Agent — a small "respond directly" Machine to trim down and build on — and
7
- // `images/default/Dockerfile`, the Sandbox Image every Workspace falls back to (ADR-0037).
7
+ // `images/default/Dockerfile`, the Sandbox Image every Workspace falls back to (ADR-0037), and
8
+ // the `AGENTS.md` an Assistant reads first, with a `CLAUDE.md` that imports it (ADR-0072).
8
9
  //
9
10
  // What it does NOT scaffold is as deliberate: there is no `agents/` folder and no `images/` scan,
10
11
  // because a Machine carries its own Agents and Sandbox Images (ADR-0049) and only what the CLI
@@ -15,7 +16,8 @@
15
16
  // `test/init.test.ts` enforces that; without it the two drift silently, and since the manifest
16
17
  // carries the kit version that same test is the version-bump tripwire (bump the kit, re-render the
17
18
  // template). Existing files are left untouched (init is additive); created paths are reported on
18
- // stderr.
19
+ // stderr. A user's own AGENTS.md or CLAUDE.md that keeps an Assistant from the docs index gets one
20
+ // hint line under its skip line (ADR-0072).
19
21
  //
20
22
  // ONE template serves both checkout and installed mode (ADR-0043) — a branch there would mean the
21
23
  // tested output and the shipped output diverge. So the scaffold names no package manager, and pins
@@ -24,20 +26,32 @@
24
26
  // not the orchestrator's `KIT_VERSION`: a global `jr2` with no Instance around it must scaffold
25
27
  // without a peer resolved, and the two numbers are equal by construction (one release train,
26
28
  // lockstep — ADR-0055).
29
+ //
30
+ // The `AGENTS.md` block is the one scaffolded text that comes from the Kit, not from this file: the
31
+ // bundle of the CLI's own peer `@jr2/orchestrator`, when that peer is the version the scaffold pins.
32
+ // A global `jr2` has that peer (npm installs a global's peers with it, and a checkout links it), so
33
+ // a fresh folder indexes its docs before the first install — an Assistant can write the first
34
+ // Machine before the first `jr2 up`. Without the bundle (a checkout before `docs:build`), the
35
+ // markers stay empty and `jr2 up` fills them. The template mirrors the file with the block taken
36
+ // out, since the block is generated and nothing generated is committed (ADR-0072).
27
37
 
28
38
  import { mkdir, readFile, writeFile } from "node:fs/promises";
29
39
  import { basename, dirname, join, resolve } from "node:path";
30
- import { parseArgs } from "node:util";
31
- import { CLI_VERSION } from "../kit-version.ts";
40
+ import { parseCommand } from "../command-table.ts";
41
+ import {
42
+ AGENTS_MD,
43
+ BEGIN_MARKER,
44
+ CLAUDE_MD,
45
+ END_MARKER,
46
+ placeAgentsBlock,
47
+ readAgentsBlock,
48
+ scaffoldAgentsMd,
49
+ } from "../agents-md.ts";
50
+ import { CLI_ROOT, CLI_VERSION, resolvePackage, type ResolvedPackage } from "../kit-version.ts";
32
51
  import { activity, type Io } from "../output.ts";
33
52
 
34
53
  export async function init(args: string[], io: Io): Promise<number> {
35
- const { values, positionals } = parseArgs({
36
- args,
37
- allowPositionals: true,
38
- strict: false,
39
- options: { name: { type: "string" } },
40
- });
54
+ const { values, positionals } = parseCommand("init", args);
41
55
 
42
56
  const dir = positionals[0] ? resolve(io.cwd, String(positionals[0])) : io.cwd;
43
57
  const name = (values.name as string | undefined) ?? basename(dir);
@@ -49,6 +63,8 @@ export async function init(args: string[], io: Io): Promise<number> {
49
63
  { path: ".gitignore", content: GITIGNORE },
50
64
  { path: join("workflows", "ping.ts"), content: PING_TS },
51
65
  { path: join("images", "default", "Dockerfile"), content: IMAGE_DOCKERFILE },
66
+ { path: AGENTS_MD, content: scaffoldAgentsMd(scaffoldBlock(resolvePackage("@jr2/orchestrator", CLI_ROOT))) },
67
+ { path: "CLAUDE.md", content: CLAUDE_MD },
52
68
  ];
53
69
 
54
70
  activity(io, `jr2 init — scaffolding ${name} in ${dir}`);
@@ -56,6 +72,8 @@ export async function init(args: string[], io: Io): Promise<number> {
56
72
  const full = join(dir, file.path);
57
73
  if (await exists(full)) {
58
74
  activity(io, ` skip ${file.path} (exists)`);
75
+ const hint = await existingHint(file.path, full);
76
+ if (hint) activity(io, ` ${hint}`);
59
77
  continue;
60
78
  }
61
79
  await mkdir(dirname(full), { recursive: true });
@@ -66,6 +84,29 @@ export async function init(args: string[], io: Io): Promise<number> {
66
84
  return 0;
67
85
  }
68
86
 
87
+ /** The block `init` writes between the markers: the bundle of `peer` when it is the exact version
88
+ * the scaffold pins, else nothing (`jr2 up` fills it once the Instance resolves its Kit). */
89
+ export function scaffoldBlock(peer: ResolvedPackage | undefined, pinned: string = CLI_VERSION): string | undefined {
90
+ return peer && peer.version === pinned ? readAgentsBlock(peer.root) : undefined;
91
+ }
92
+
93
+ /** One line for a user's own `AGENTS.md` or `CLAUDE.md` that init left alone and that keeps an
94
+ * Assistant from the docs index (ADR-0072): a file with no markers has opted out, and Claude Code
95
+ * reads `AGENTS.md` only through a `CLAUDE.md` that imports it. Said, never fixed: the file is the
96
+ * user's. */
97
+ export async function existingHint(path: string, full: string): Promise<string | undefined> {
98
+ if (path === AGENTS_MD) {
99
+ const place = placeAgentsBlock(await readFile(full, "utf8"));
100
+ if (place.kind === "opted-out")
101
+ return `AGENTS.md has no jr2-docs markers: add the lines ${BEGIN_MARKER} and ${END_MARKER}, and jr2 up fills them`;
102
+ if (place.kind === "broken") return `AGENTS.md: ${place.reason}, so jr2 up does not update the jr2 block`;
103
+ return undefined;
104
+ }
105
+ if (path === "CLAUDE.md" && !/^@AGENTS\.md\s*$/m.test(await readFile(full, "utf8")))
106
+ return "CLAUDE.md does not import AGENTS.md: add the line @AGENTS.md, so that Claude Code reads it";
107
+ return undefined;
108
+ }
109
+
69
110
  async function exists(path: string): Promise<boolean> {
70
111
  try {
71
112
  await readFile(path);
@@ -85,6 +126,17 @@ async function exists(path: string): Promise<boolean> {
85
126
  // rule for `@jr2/*`, applied to the checker): the instance's PROGRAM includes the kit's `.ts`
86
127
  // sources — zero-build, `exports` point at source — so the compiler is part of the contract, not
87
128
  // the user's choice, and it moves when the kit moves.
129
+ //
130
+ // `zod` is the other import the scaffold's own files do not make but every Machine an author writes
131
+ // does: each defineEvent and each input schema is a zod object, and the Reference's examples start
132
+ // with `import { z } from "zod"`. A strict node_modules (pnpm) does not let the Instance import a
133
+ // package it does not list, so without it a Machine copied from the bundled Reference is refused by
134
+ // `jr2 up`'s typecheck (ADR-0050). npm hoisted it, by accident. The range is the kit's own.
135
+ //
136
+ // `check` runs `jr2 check`: the typecheck AND the load of each Workflow, which `jr2 up` runs first
137
+ // (ADR-0050). `typecheck` alone does not load a module, so it misses each refusal of
138
+ // `createMachine`, `defineEvent` and `workspace()`. The `jr2` it runs is the Instance's own
139
+ // `@jr2/cli`, which strips the types of the kit sources under `node_modules`.
88
140
  function packageJson(name: string): string {
89
141
  return `${JSON.stringify(
90
142
  {
@@ -92,8 +144,8 @@ function packageJson(name: string): string {
92
144
  version: "0.0.0",
93
145
  private: true,
94
146
  type: "module",
95
- scripts: { typecheck: "tsc --noEmit" },
96
- dependencies: { "@jr2/orchestrator": CLI_VERSION, xstate: "^5.18.0" },
147
+ scripts: { check: "jr2 check", typecheck: "tsc --noEmit" },
148
+ dependencies: { "@jr2/orchestrator": CLI_VERSION, xstate: "^5.18.0", zod: "^4.4.3" },
97
149
  devDependencies: { "@jr2/cli": CLI_VERSION, "@types/node": "^26.0.1", typescript: "^5.6.0" },
98
150
  },
99
151
  null,
@@ -159,7 +211,9 @@ const IMAGE_DOCKERFILE = `# A Sandbox Image (ADR-0037): the tools your agents ca
159
211
  # (\`user:\` on \`workspace()\`, ADR-0005).
160
212
  #
161
213
  # Two things jr2 cannot vendor, both proven by \`jr2 up\` before it converges:
162
- # - \`git\` — the agent clones, worktrees, and commits with the git you chose;
214
+ # - \`git\` — the agent clones, worktrees, and commits with the git you chose. With no identity
215
+ # here, the agent commits as \`jr2 Agent <agent@jr2.invalid>\`, jr2's default at the lowest rung
216
+ # of git's config: an identity you set at any level wins over it (ADR-0037);
163
217
  # - a glibc base no older than jr2's node. alpine/musl cannot run it at all.
164
218
  #
165
219
  # This one folder is a path convention; there is no \`images/\` scan (ADR-0049/0050). A SECOND Sandbox
@@ -196,9 +250,11 @@ const PING_TS = `// The simplest jr2 workflow: no Agent, no Workspace, no data p
196
250
  // Shape: take the run input, invoke a plain \`fromPromise\` actor, fold its result into context, finish.
197
251
  // \`jr2 run ping --input '{"message":"hi"}'\` → the run reaches \`done\` and \`jr2 status\` shows the reply.
198
252
  //
199
- // Module contract (ADR-0011/0015): one named export — \`machine\`. A workflow that accepts
200
- // external events authors with \`jr2Setup({ events: [...] })\`; ping accepts none, so plain
201
- // xstate \`setup()\` is all it needs.
253
+ // Module contract (ADR-0011/0015): one named export — \`machine\`. ping takes no workflow event and
254
+ // declares no door, so plain xstate \`setup()\` is enough. A Machine with a door (a zod \`input\` on
255
+ // \`createMachine\`, ADR-0033) uses \`jr2Setup\`, also when it takes no event: then it leaves out
256
+ // \`events\`, as in \`jr2Setup()\`. \`setup()\` does not read that \`input\`, so \`jr2 up\` refuses a
257
+ // zod \`input\` on a Machine that \`setup()\` made.
202
258
  //
203
259
  // The next step is a Machine the kit already ships (ADR-0054). \`@jr2/machines\` exports \`task\` — one
204
260
  // prompt, one Workspace, one human says done — and a Workflow is only the name an Instance
@@ -209,7 +265,7 @@ const PING_TS = `// The simplest jr2 workflow: no Agent, no Workspace, no data p
209
265
  //
210
266
  // export const machine = customize(task, {
211
267
  // repos: { target: { url: "https://github.com/you/repo.git" } },
212
- // agents: { coder: { model: "anthropic/claude-sonnet-4-6" } },
268
+ // agents: { coder: { model: "anthropic/claude-haiku-5-5" } },
213
269
  // });
214
270
  //
215
271
  // A packaged Machine leaves the parts it cannot honestly fill OPEN: it does not know your
@@ -24,19 +24,14 @@
24
24
  // swap — it is the decision.
25
25
 
26
26
  import { execFile } from "node:child_process";
27
- import { parseArgs, promisify } from "node:util";
28
- import { custodianRef, KIT_VERSION } from "@jr2/orchestrator";
27
+ import { promisify } from "node:util";
28
+ import { custodianRef, KIT_VERSION } from "@jr2/orchestrator/internal";
29
29
  import { KIT_IMAGES, publishedKitRefs, type KitImageName } from "../build.ts";
30
30
  import { activity, result, type Io } from "../output.ts";
31
+ import { commandNamed, parseCommand, renderCommand } from "../command-table.ts";
31
32
 
32
33
  const exec = promisify(execFile);
33
34
 
34
- const USAGE = `usage: jr2 kit push <registry>
35
-
36
- mirror the v${KIT_VERSION} kit images (harness, operator, orchestrator) from their canonical
37
- home, and the Custodian's pinned Envoy from its own, into <registry>, for a cluster whose
38
- \`kitRegistry\` names it (ADR-0044, ADR-0059, ADR-0069)`;
39
-
40
35
  /** Run one `docker` invocation, rejecting on a non-zero exit. The seam this command is tested
41
36
  * through: the claims are which argv each ref produces and what a failure means, and both are
42
37
  * decidable with no registry anywhere near the test. Narrower than `build.ts`'s `RunCommand` on
@@ -51,17 +46,17 @@ const dockerCli: RunDocker = async (args) => {
51
46
  type Mirrored = { image: KitImageName | "custodian"; source: string; target: string; copied: boolean };
52
47
 
53
48
  export async function kit(args: string[], io: Io, docker: RunDocker = dockerCli): Promise<number> {
54
- const { positionals } = parseArgs({ args, allowPositionals: true, strict: false, options: {} });
49
+ const { positionals } = parseCommand("kit push", args);
55
50
  const [sub, registry] = positionals;
56
51
 
57
52
  if (sub !== "push") {
58
53
  activity(io, sub === undefined ? "jr2 kit: no subcommand" : `unknown kit subcommand: ${sub}`);
59
- activity(io, USAGE);
54
+ activity(io, renderCommand(commandNamed("kit push")));
60
55
  return 2;
61
56
  }
62
57
  if (!registry) {
63
58
  activity(io, "jr2 kit push: no target registry");
64
- activity(io, USAGE);
59
+ activity(io, renderCommand(commandNamed("kit push")));
65
60
  return 2;
66
61
  }
67
62
 
@@ -3,23 +3,18 @@
3
3
  // author emits → stderr). `-f`/`--follow` keeps streaming status deltas until the run settles; without
4
4
  // it, we print the replayed status and stop. A run that already settled streams its final status once.
5
5
 
6
- import { parseArgs } from "node:util";
7
6
  import { JR2Client } from "../client.ts";
8
- import { resolveTarget, TARGET_ARGS, targetOptions } from "../instance.ts";
7
+ import { resolveTarget, targetOptions } from "../instance.ts";
9
8
  import { activity, result, type Io } from "../output.ts";
10
9
  import { resolveRunId } from "../run-id.ts";
11
10
  import { childErrorActivity, placingActivity } from "./run.ts";
11
+ import { parseCommand, usageLine } from "../command-table.ts";
12
12
 
13
13
  export async function logs(args: string[], io: Io): Promise<number> {
14
- const { values, positionals } = parseArgs({
15
- args,
16
- allowPositionals: true,
17
- strict: false,
18
- options: { follow: { type: "boolean", short: "f" }, ...TARGET_ARGS },
19
- });
14
+ const { values, positionals } = parseCommand("logs", args);
20
15
  const given = positionals[0];
21
16
  if (!given) {
22
- activity(io, "usage: jr2 logs <runId|abbrev> [-f]");
17
+ activity(io, usageLine("logs"));
23
18
  return 2;
24
19
  }
25
20
  const target = await resolveTarget(io, targetOptions(values));
@@ -11,28 +11,25 @@
11
11
  // A named Chart is a Workflow (ADR-0070): `jr2 run <wrapper>/<name>` starts it as any other run, the
12
12
  // name riding the path as one encoded segment. A Chart the door refuses (one-shot, in `--input`)
13
13
  // is a 422 whose report lines this prints, one per rule the Chart fails.
14
+ //
15
+ // The exit code (ADR-0073): 0 only for a run that ended `done`. 1 for every other end — `failed`,
16
+ // `error`, `cancelled`, `lost`, `drifted` — and for a run that was parked before it settled. 2 is a
17
+ // usage error, as for every verb. The status line on stderr says which end it was, and a failed
18
+ // run's reason follows it.
14
19
 
15
- import { parseArgs } from "node:util";
16
- import type { ChildErrorMarker, PlacingMarker } from "@jr2/orchestrator";
20
+ import type { ChildErrorMarker, PlacingMarker } from "@jr2/orchestrator/internal";
17
21
  import { JR2Client, JR2HttpError } from "../client.ts";
18
- import { resolveTarget, TARGET_ARGS, targetOptions } from "../instance.ts";
22
+ import { failureActivity } from "./status.ts";
23
+ import { resolveTarget, targetOptions } from "../instance.ts";
19
24
  import { activity, result, type Io } from "../output.ts";
25
+ import { parseCommand, usageLine } from "../command-table.ts";
20
26
 
21
27
  export async function run(args: string[], io: Io): Promise<number> {
22
- const { values, positionals } = parseArgs({
23
- args,
24
- allowPositionals: true,
25
- strict: false,
26
- options: {
27
- input: { type: "string" },
28
- detach: { type: "boolean" },
29
- ...TARGET_ARGS,
30
- },
31
- });
28
+ const { values, positionals } = parseCommand("run", args);
32
29
 
33
30
  const workflow = positionals[0];
34
31
  if (!workflow) {
35
- activity(io, "usage: jr2 run <workflow | wrapper/chart> [--input <json>] [--detach]");
32
+ activity(io, usageLine("run"));
36
33
  return 2;
37
34
  }
38
35
  const input = values.input ? (JSON.parse(String(values.input)) as Record<string, unknown>) : {};
@@ -73,8 +70,10 @@ export async function run(args: string[], io: Io): Promise<number> {
73
70
  }
74
71
  activity(io, ` → ${ev.status.status} ${JSON.stringify(ev.status.value)}`);
75
72
  if (ev.status.status !== "active") {
73
+ const failed = failureActivity(ev.status);
74
+ if (failed) activity(io, failed);
76
75
  result(io, ev.status);
77
- return ev.status.status === "error" ? 1 : 0;
76
+ return ev.status.status === "done" ? 0 : 1;
78
77
  }
79
78
  }
80
79
  // `events()` re-attaches until the run settles (ADR-0022), so reaching here means the run left
@@ -4,6 +4,8 @@
4
4
  // A run with a Workspace in `placing` (ADR-0064) carries `placing`: the child paths of those
5
5
  // Workspaces, in `RunWaiting.child`'s form. It is the at-a-glance marker for a run that waits for
6
6
  // capacity — why it waits is `jr2 status <runId>`'s `waiting`. A run with none has no `placing` key.
7
+ // A dispatched run (ADR-0071) carries `dispatchedBy` after its id, as the Orchestrator sends it: the
8
+ // run that started it, the caller's slot and the chain's depth, so the chain reads off the listing.
7
9
  //
8
10
  // The verb also lists the Workflows a run can start (ADR-0070), from `GET /workflows`, as activity on
9
11
  // stderr so the result on stdout stays the live runs: one line per Workflow with its origin
@@ -11,32 +13,14 @@
11
13
  // with its reason. `--workflows` prints that listing as the result instead. No verb puts or deletes
12
14
  // a runtime Chart in this step: `PUT`/`DELETE /workflows/<wrapper>/charts/<name>` is for an MCP or curl.
13
15
 
14
- import { parseArgs } from "node:util";
15
- import type { RunChild } from "@jr2/orchestrator";
16
+ import type { RunChild } from "@jr2/orchestrator/internal";
16
17
  import { JR2Client, type RunStatus, type WorkflowListing, type WorkflowOrigin } from "../client.ts";
17
- import { resolveTarget, TARGET_ARGS, targetOptions } from "../instance.ts";
18
+ import { resolveTarget, targetOptions } from "../instance.ts";
18
19
  import { activity, result, type Io } from "../output.ts";
19
-
20
- export const RUNS_USAGE = `usage: jr2 runs [--workflows]
21
-
22
- the live runs as JSON on stdout; the Workflows a run can start on stderr, each with its origin
23
- (machine, chart file, chart) and a Chart wrapper's Charts indented under it (ADR-0070)
24
- --workflows print the Workflow listing as the JSON result instead
25
-
26
- a runtime Chart has no verb here: PUT or DELETE /workflows/<wrapper>/charts/<name> with the
27
- Instance token (an MCP or curl), then \`jr2 run <wrapper>/<name>\` starts it`;
20
+ import { parseCommand } from "../command-table.ts";
28
21
 
29
22
  export async function runs(args: string[], io: Io): Promise<number> {
30
- const { values } = parseArgs({
31
- args,
32
- allowPositionals: true,
33
- strict: false,
34
- options: { ...TARGET_ARGS, workflows: { type: "boolean" }, help: { type: "boolean", short: "h" } },
35
- });
36
- if (values.help) {
37
- activity(io, RUNS_USAGE);
38
- return 0;
39
- }
23
+ const { values } = parseCommand("runs", args);
40
24
  const target = await resolveTarget(io, targetOptions(values));
41
25
  try {
42
26
  const client = new JR2Client(target.url, io.fetch, target.token);
@@ -44,7 +28,7 @@ export async function runs(args: string[], io: Io): Promise<number> {
44
28
  result(io, await client.workflows());
45
29
  return 0;
46
30
  }
47
- const live = (await client.list()).map(markPlacing);
31
+ const live = (await client.list()).map(markListed);
48
32
  // A report beside the result: a listing that cannot be read says why, and the runs still print.
49
33
  try {
50
34
  activity(io, renderWorkflows(await client.workflows()));
@@ -93,12 +77,13 @@ function chartFullName(wrapper: string, name: string): string {
93
77
  return name.includes("/") ? name : `${wrapper}/${name}`;
94
78
  }
95
79
 
96
- /** The run, with `placing` after its id when any Workspace in it is in `placing`. */
97
- export function markPlacing(run: RunStatus): RunStatus & { placing?: string[] } {
80
+ /** The run as `jr2 runs` lists it: `dispatchedBy` after its id when a `dispatch` Effect started it
81
+ * (ADR-0071), and `placing` after that when any Workspace in it is in `placing`. */
82
+ export function markListed(run: RunStatus): RunStatus & { placing?: string[] } {
98
83
  const placing = [...(run.value === "placing" ? [""] : []), ...placingIn(run.children ?? [], [])];
99
- if (!placing.length) return run;
100
- const { runId, ...rest } = run;
101
- return { runId, placing, ...rest };
84
+ if (!placing.length && !run.dispatchedBy) return run;
85
+ const { runId, dispatchedBy, ...rest } = run;
86
+ return { runId, ...(dispatchedBy ? { dispatchedBy } : {}), ...(placing.length ? { placing } : {}), ...rest };
102
87
  }
103
88
 
104
89
  /** The paths of every child in `placing`, at any depth — read off the state values alone, which
@@ -20,33 +20,19 @@
20
20
  // idle timeout (ADR-0025). It is a fact of this cancel, so it is a flag here and never a config key,
21
21
  // and it means nothing on any other event — refused there rather than ignored.
22
22
 
23
- import { parseArgs } from "node:util";
24
23
  import { JR2Client } from "../client.ts";
25
- import { resolveTarget, TARGET_ARGS, targetOptions } from "../instance.ts";
24
+ import { resolveTarget, targetOptions } from "../instance.ts";
26
25
  import { activity, type Io } from "../output.ts";
27
26
  import { resolveRunId } from "../run-id.ts";
28
-
29
- const USAGE =
30
- "usage: jr2 send <runId|abbrev> --event CANCEL [--keep] | jr2 send <runId|abbrev> --gate <gate> --event <name> [--input '<json>']";
27
+ import { commandNamed, parseCommand, renderCommand } from "../command-table.ts";
31
28
 
32
29
  export async function send(args: string[], io: Io): Promise<number> {
33
- const { values, positionals } = parseArgs({
34
- args,
35
- allowPositionals: true,
36
- strict: false,
37
- options: {
38
- event: { type: "string" },
39
- gate: { type: "string" },
40
- input: { type: "string" },
41
- keep: { type: "boolean" },
42
- ...TARGET_ARGS,
43
- },
44
- });
30
+ const { values, positionals } = parseCommand("send", args);
45
31
  const given = positionals[0];
46
32
  const gate = values.gate as string | undefined;
47
33
  const event = values.event as string | undefined;
48
34
  if (!given || !event) {
49
- activity(io, USAGE);
35
+ activity(io, renderCommand(commandNamed("send")));
50
36
  return 2;
51
37
  }
52
38
  const keep = values.keep === true;
@@ -14,21 +14,24 @@
14
14
  //
15
15
  // A run that binds a Chart (ADR-0070) carries `bound: true`; the verb says so beside the result as
16
16
  // `chart: bound`, with the route that draws the Machine the run binds, its Charts compiled in.
17
+ //
18
+ // A dispatched run (ADR-0071) says beside the result who dispatched it: `dispatched by: <run>
19
+ // (<slot>), depth <n>`. A run that dispatched says the other direction, one line per run it
20
+ // started: `dispatched: <run> (<slot>)`. The result itself carries `dispatchedBy`, `dispatched`
21
+ // and, once the run is no longer live, its `output`, as the Orchestrator sends them.
22
+ //
23
+ // A failed run (ADR-0073) says its reason beside the result: `failed: <reason>`. The result's
24
+ // `output` holds the whole Failure, `{ reason, detail? }`.
17
25
 
18
- import { parseArgs } from "node:util";
19
- import type { AssemblyView, RepoStatus } from "@jr2/orchestrator";
26
+ import type { AssemblyView, RepoStatus } from "@jr2/orchestrator/internal";
20
27
  import { JR2Client, type FetchLike, type RunStatus, workflowPath } from "../client.ts";
21
- import { resolveTarget, TARGET_ARGS, targetOptions } from "../instance.ts";
28
+ import { resolveTarget, targetOptions } from "../instance.ts";
22
29
  import { activity, result, type Io } from "../output.ts";
23
30
  import { resolveRunId } from "../run-id.ts";
31
+ import { parseCommand } from "../command-table.ts";
24
32
 
25
33
  export async function status(args: string[], io: Io): Promise<number> {
26
- const { values, positionals } = parseArgs({
27
- args,
28
- allowPositionals: true,
29
- strict: false,
30
- options: { ...TARGET_ARGS, json: { type: "boolean" } },
31
- });
34
+ const { values, positionals } = parseCommand("status", args);
32
35
  // NO run named, not an EMPTY one: `jr2 status "$RUNID"` with the variable unset asked about a run
33
36
  // and got an empty string, and answering that with the instance's repos would report exit 0 on a
34
37
  // question nobody asked. An empty positional falls through to the resolver, which says so.
@@ -48,14 +51,47 @@ export async function status(args: string[], io: Io): Promise<number> {
48
51
  return 1;
49
52
  }
50
53
  result(io, s);
51
- const note = boundActivity(s);
52
- if (note) activity(io, note);
54
+ const notes = [failureActivity(s), dispatchedActivity(s), ...dispatchedLines(s), boundActivity(s)];
55
+ for (const note of notes) if (note) activity(io, note);
53
56
  return 0;
54
57
  } finally {
55
58
  target.close?.();
56
59
  }
57
60
  }
58
61
 
62
+ /** The `failed:` line for a run that ended `failed` (ADR-0073): the Failure's reason, read from its
63
+ * output. Undefined for every other status, and for an output with no reason text. */
64
+ export function failureActivity(s: Pick<RunStatus, "status" | "output">): string | undefined {
65
+ if (s.status !== "failed") return undefined;
66
+ const reason = (s.output as { reason?: unknown } | undefined)?.reason;
67
+ return typeof reason === "string" ? `failed: ${reason}` : undefined;
68
+ }
69
+
70
+ /** The most `dispatched:` lines `jr2 status` prints: a watcher dispatches on every pass, and the
71
+ * result carries the whole list. */
72
+ const DISPATCHED_LINES = 10;
73
+
74
+ /** The `dispatched:` lines for the runs this run's `dispatch` Effects started (ADR-0071), newest
75
+ * last, the older ones counted. */
76
+ export function dispatchedLines(s: RunStatus): string[] {
77
+ const all = s.dispatched ?? [];
78
+ const shown = all.slice(-DISPATCHED_LINES);
79
+ const older = all.length - shown.length;
80
+ return [
81
+ ...(older > 0 ? [`dispatched: ${older} older run(s); the result lists them all`] : []),
82
+ ...shown.map((d) => `dispatched: ${d.run} (${d.slot})`),
83
+ ];
84
+ }
85
+
86
+ /** The `dispatched by:` line for a run a `dispatch` Effect started (ADR-0071), or undefined for a
87
+ * run nobody dispatched. A foreign caller is named by its Orchestrator, as that one sent it: opaque
88
+ * text, which this Orchestrator could not verify. */
89
+ export function dispatchedActivity(s: RunStatus): string | undefined {
90
+ const by = s.dispatchedBy;
91
+ if (!by) return undefined;
92
+ return `dispatched by: ${by.run} (${by.slot}), depth ${by.depth}${by.instance ? `, from Orchestrator ${by.instance}` : ""}`;
93
+ }
94
+
59
95
  /** The `chart: bound` line for a run that binds a Chart (ADR-0070), or undefined for one that binds
60
96
  * none. The Workflow name rides the route as one encoded segment. */
61
97
  export function boundActivity(s: RunStatus): string | undefined {