@awebai/oats 0.42.0 → 0.43.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/bin/oats.mjs CHANGED
@@ -47,13 +47,13 @@ import { attachArgv, checkRemote, connectServer, forgetSnapshot, getServer, insp
47
47
  import { spawnSync as spawnSyncProc } from "node:child_process";
48
48
  import { tickTriggers } from "../lib/triggers.mjs";
49
49
  import * as A from "../lib/automations.mjs";
50
- import { parseEnvelopeText, unresolvedScheduleAttempts, scheduleScopeOf, listSchedules, describe as describeSchedule, testSchedule, addSchedule, updateSchedule, setEnabled as setScheduleEnabled, removeSchedule, runNow as runScheduleNow, reconcile as reconcileSchedule, tickHost, tickWorkspace, scopeAutomations, scheduleKind, registerWorkspace, unregisterWorkspace, readRegistry, schedulerStatus, saveWakeForHome, removeWakeForHome, wakeFromFlags, withHostLock, scheduleError, SCHEDULE_API } from "../lib/schedule.mjs";
50
+ import { parseEnvelopeText, unresolvedScheduleAttempts, scheduleScopeOf, listSchedules, describe as describeSchedule, testSchedule, addSchedule, updateSchedule, updateScheduleDescription, setEnabled as setScheduleEnabled, removeSchedule, runNow as runScheduleNow, reconcile as reconcileSchedule, tickHost, tickWorkspace, scopeAutomations, scheduleKind, registerWorkspace, unregisterWorkspace, readRegistry, schedulerStatus, saveWakeForHome, removeWakeForHome, wakeFromFlags, withHostLock, scheduleError, SCHEDULE_API } from "../lib/schedule.mjs";
51
51
  import { hostUnitStatus, installHostUnit, uninstallHostUnit } from "../lib/schedule-host.mjs";
52
52
  import { receiveAttachment, uploadAttachment, readStreamBounded, MAX_ATTACHMENT_BYTES } from "../lib/attachments.mjs";
53
53
 
54
54
  import { observeInstanceGit, diffInstanceFile } from "../lib/instance-git.mjs";
55
55
  import { planStop, applyStop, planRetire, resolveInstance as resolveInstanceForCli } from "../lib/instance-lifecycle.mjs";
56
- import { formatBytes, workRecoveryLines } from "../lib/retire-output.mjs";
56
+ import { extraWorktreeLines, formatBytes, workRecoveryLines } from "../lib/retire-output.mjs";
57
57
  const await_import_lifecycle = () => ({ resolveInstance: resolveInstanceForCli });
58
58
  import { homeTarget, soulTarget, isWorkspaceContext, inspectDocument, readinessDocument, policyOf, policySoul, manifestMissingRequires, INSPECT_OPERATIONS_API } from "../lib/instance-inspect.mjs";
59
59
  import { readEvents, setWaiting, incarnationOf } from "../lib/instance-events.mjs";
@@ -69,6 +69,9 @@ const KERNEL_SWITCHES = new Set(["allow-child-spawns", "apply", "check", "clear"
69
69
  * value is kept out of argv, where it would read as a flag (`--message=--clear` must not
70
70
  * clear), and flag() answers it from `inline`. */
71
71
  const INLINE_TEXT_FLAGS = new Set(["message"]);
72
+ /** Free-text flags whose inline value is always kept whole, the empty one included:
73
+ * `--description=` clears a trigger's or schedule's description. */
74
+ const INLINE_WHOLE_FLAGS = new Set(["description"]);
72
75
  function expandInlineValues(argv) {
73
76
  const out = [];
74
77
  const inline = new Map();
@@ -77,7 +80,7 @@ function expandInlineValues(argv) {
77
80
  const eq = a.indexOf("=");
78
81
  if (!a.startsWith("--") || eq <= 2) { out.push(a); continue; }
79
82
  const name = a.slice(2, eq), value = a.slice(eq + 1);
80
- if (INLINE_TEXT_FLAGS.has(name) && value.startsWith("--")) { inline.set(name, value); out.push(`--${name}`); continue; }
83
+ if (INLINE_WHOLE_FLAGS.has(name) || (INLINE_TEXT_FLAGS.has(name) && value.startsWith("--"))) { inline.set(name, value); out.push(`--${name}`); continue; }
81
84
  problem ??= KERNEL_SWITCHES.has(name) ? `--${name} takes no value (got ${a})` : value === "" ? `--${name}= needs a value` : value.startsWith("--") ? `--${name}= takes a value, not an option (got ${a})` : undefined;
82
85
  out.push(`--${name}`, value);
83
86
  }
@@ -105,6 +108,20 @@ function valueFlag(name) {
105
108
  if (value === true) cmdFail("E_BAD_ARGS", `--${name} needs a value`);
106
109
  return value;
107
110
  }
111
+ /** `--description=<text>` on the trigger and schedule verbs: undefined when absent, the text
112
+ * otherwise (`""` clears). The spaced form works too, but only `=` carries an empty value or one
113
+ * that starts with `--`. */
114
+ function descriptionFlag() {
115
+ const value = flag("description");
116
+ if (value === true) throw Object.assign(new Error("--description needs a value: --description=<text> (--description= clears it)"), { code: "E_BAD_ARGS" });
117
+ return value;
118
+ }
119
+ /** A spec with the --description flag applied: it sets or overrides `description`; `""` removes it. */
120
+ function withDescription(spec, description) {
121
+ if (description === undefined || !spec || typeof spec !== "object" || Array.isArray(spec)) return spec;
122
+ const { description: _d, ...rest } = spec; void _d;
123
+ return description === "" ? rest : { ...rest, description };
124
+ }
108
125
  const die = (msg, exit = 1) => { console.error(`oats: ${msg}`); process.exit(exit); };
109
126
  /** A command's harness: --harness, or --runtime, its pre-0.27 name (the released okf worker and
110
127
  * a 0.26-era Desktop pass it) — read either, with the deprecation warning. Both, disagreeing,
@@ -1430,9 +1447,11 @@ async function automationsCmd() {
1430
1447
  /** `oats trigger|schedule add --workspace <member> --runs-on <host> --owner <host>/<login>`: the
1431
1448
  * workspace file, written into the member's checkout when `--dir` (or the cwd) is inside one, else
1432
1449
  * printed. What is written must read back as the same automation (parseAutomationFile + expand). */
1433
- async function addWorkspaceAutomation(desc, { id, body }) {
1450
+ async function addWorkspaceAutomation(desc, { id, body, description }) {
1434
1451
  const bail = (code, msg, details) => { throw Object.assign(new Error(msg), { code, details }); };
1435
- const member = flag("workspace"), runsOn = flag("runs-on"), owner = flag("owner"), description = flag("description");
1452
+ const member = flag("workspace"), runsOn = flag("runs-on"), owner = flag("owner");
1453
+ // The header's description: refused here when out of the rule (discovery would only warn).
1454
+ if (description !== undefined && description !== "") A.validateDescription(description, desc.kind === "trigger" ? "E_TRIGGER_INVALID" : "E_SCHEDULE_INVALID");
1436
1455
  if (typeof member !== "string" || !member) bail("E_BAD_ARGS", "--workspace needs a member name");
1437
1456
  if (typeof runsOn !== "string" || !A.HOST_NAME_RE.test(runsOn)) bail("E_BAD_ARGS", "--runs-on <host>: the host.name (oats-local.yaml) of the machine that runs it");
1438
1457
  if (!A.parseOwner(owner)) bail("E_BAD_ARGS", "--owner <host>/<login>: the GitHub account it acts as, e.g. github.com/acme-kb-bot");
@@ -1441,7 +1460,7 @@ async function addWorkspaceAutomation(desc, { id, body }) {
1441
1460
  const row = (discovery.members || []).find((m) => m.confirmed && (memberLabel(m.key) === member || m.key === member));
1442
1461
  if (!row) bail("E_AUTOMATION_MEMBER", `${member} is not a confirmed member of this workspace (members: ${(discovery.members || []).filter((m) => m.confirmed).map((m) => memberLabel(m.key)).join(", ") || "none"})`, { member });
1443
1462
  const name = memberLabel(row.key);
1444
- const content = A.automationFileText(desc, { id, description: typeof description === "string" ? description : undefined, runsOn, owner, body });
1463
+ const content = A.automationFileText(desc, { id, description: description || undefined, runsOn, owner, body });
1445
1464
  const parsed = A.parseAutomationFile(desc, { stem: id, path: `${desc.folder}/${id}.yaml`, bytes: Buffer.from(content), member: name, repoKey: row.key, commit: row.commit });
1446
1465
  if (parsed.problem) bail(parsed.problem.code, parsed.problem.message, { path: parsed.problem.path });
1447
1466
  await desc.expand(parsed.entry);
@@ -2498,7 +2517,7 @@ function retireCmd() {
2498
2517
  const planRev = flag("plan-revision"), idemKey = flag("idempotency-key");
2499
2518
  if (planRev === true || idemKey === true) die("--plan-revision and --idempotency-key need values");
2500
2519
  if ((planRev !== undefined) !== (idemKey !== undefined)) die("--plan-revision and --idempotency-key go together");
2501
- let replayPath = null, childrenStopped = null;
2520
+ let replayPath = null, childrenStopped = null, plannedExtraWorktrees;
2502
2521
  if (planRev !== undefined) {
2503
2522
  if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(idemKey)) die("--idempotency-key: 1-128 chars of [A-Za-z0-9._:-]");
2504
2523
  // Replay first: after a successful retire the home is gone, so the receipt
@@ -2508,6 +2527,9 @@ function retireCmd() {
2508
2527
  let fresh;
2509
2528
  try { fresh = planRetire(dirFlag(), root, name, { home: homeFlag }); } catch (e) { return args.includes("--json") ? jsonFail(e.code || "E_LIFECYCLE_FAILED", e.message, e.details) : die(e.message); }
2510
2529
  replayPath = join(dirname(fresh.home), `.oats-retire-receipt.${idemKey}.json`);
2530
+ // The extra trees the confirmed plan names, as it names them: the retire refuses as stale rather than
2531
+ // move or remove one that no longer reads that way when it gets to them.
2532
+ plannedExtraWorktrees = fresh.facts.extraWorktrees;
2511
2533
  if (fresh.planRevision !== planRev) return args.includes("--json") ? jsonFail("E_PLAN_STALE", `the retire plan changed since it was shown (${planRev} → ${fresh.planRevision}); review the fresh plan`, { plan: fresh }) : die(`the retire plan changed since it was shown; re-run oats retire ${name} --plan`);
2512
2534
  // The plan promised: recorded children are STOPPED first (bounded, never
2513
2535
  // escalated) and retained. A child still running after the grace refuses
@@ -2521,7 +2543,7 @@ function retireCmd() {
2521
2543
  if (running.length) return args.includes("--json") ? jsonFail("E_CHILDREN_RUNNING", `${running.map((k) => k.instance).join(", ")} ${running.length === 1 ? "is" : "are"} still running after a bounded stop; nothing was retired and nothing was escalated`, { childrenStopped, plan: fresh }) : die(`children still running: ${running.map((k) => k.instance).join(", ")}; nothing retired`);
2522
2544
  }
2523
2545
  let r;
2524
- try { r = retireInstance(root, name, { home: homeFlag, self: isSelf, discardWorktree: args.includes("--discard-worktree"), keepDir: args.includes("--keep-dir"), force: args.includes("--force") }); }
2546
+ try { r = retireInstance(root, name, { home: homeFlag, self: isSelf, discardWorktree: args.includes("--discard-worktree"), keepDir: args.includes("--keep-dir"), force: args.includes("--force"), ...(plannedExtraWorktrees ? { plannedExtraWorktrees } : {}) }); }
2525
2547
  catch (e) { if (!e?.code) throw e; return args.includes("--json") ? jsonFail(e.code, e.message, e.candidates ? { ...e.details, candidates: e.candidates } : e.details) : die(e.message); }
2526
2548
  if (childrenStopped) r.childrenStopped = childrenStopped;
2527
2549
  if (replayPath) { r.planRevision = planRev; r.idempotencyKey = idemKey; r.replayed = false; try { writeFileAtomic(replayPath, JSON.stringify(r, null, 2)); } catch { /* receipt is evidence, not authority */ } }
@@ -2563,6 +2585,7 @@ function retireCmd() {
2563
2585
  // Preserving work and not saying so leaves the operator believing it is gone,
2564
2586
  // which is most of the harm of deleting it. Name the classes and the path.
2565
2587
  for (const line of workRecoveryLines(r)) console.log(line);
2588
+ for (const line of extraWorktreeLines(r)) console.log(line);
2566
2589
  for (const w of r.warnings || []) console.log(` WARNING: ${w}`);
2567
2590
  if (isSelf) console.log("This window dies in ~8s — say any goodbyes now.");
2568
2591
  }
@@ -2599,19 +2622,25 @@ async function scheduleCmd() {
2599
2622
  case "show": return out({ schedule: describeSchedule(ws(), needId(), io, { ctx: ctx() }) });
2600
2623
  case "test": return out({ test: testSchedule(ws(), needId(), io, { ctx: ctx() }) }, (r) => `${r.test.qualifiedId}: ${r.test.placement.runsHere ? "runs here" : `not here (${r.test.placement.reason ?? "disabled here"})`}${r.test.soul ? `; soul ${r.test.soul.name} ${r.test.soul.resolves ? "resolves" : `does NOT resolve (${r.test.soul.error?.code})`}` : ""}; next due ${r.test.nextDue ?? "never"}${r.test.problems.length ? `\n ${r.test.problems.join("\n ")}` : ""}\n(spawned nothing)`);
2601
2624
  case "add": {
2602
- const spec = readSpec();
2625
+ const spec = withDescription(readSpec(), descriptionFlag());
2603
2626
  if (flag("workspace") !== undefined) {
2604
2627
  const wid = id ?? spec.id;
2605
2628
  if (typeof wid !== "string") throw scheduleError("E_BAD_ARGS", "oats schedule add <id> --workspace <member> …: the id names the file (oats-schedules/<id>.yaml)");
2606
- const { id: _i, enabled: _e, kind, ...rest } = spec; void _i; void _e;
2607
- const r = await addWorkspaceAutomation(scheduleKind({ dep: ws() }), { id: wid, body: { run: kind ?? "spawn", ...rest } });
2629
+ // A description is the file header's, never the body's.
2630
+ const { id: _i, enabled: _e, kind, description, ...rest } = spec; void _i; void _e;
2631
+ const r = await addWorkspaceAutomation(scheduleKind({ dep: ws() }), { id: wid, body: { run: kind ?? "spawn", ...rest }, description });
2608
2632
  return out(r, printWorkspaceAdd);
2609
2633
  }
2610
2634
  if (id && spec.id === undefined) spec.id = id;
2611
2635
  if (id && spec.id !== id) throw scheduleError("E_SCHEDULE_INVALID", `id ${JSON.stringify(spec.id)} in the file does not match ${JSON.stringify(id)}`, { field: "id" });
2612
2636
  return out({ schedule: addSchedule(ws(), spec, io) });
2613
2637
  }
2614
- case "update": return out({ schedule: updateSchedule(ws(), needId(), readSpec(), io) });
2638
+ case "update": {
2639
+ const description = descriptionFlag();
2640
+ // --description alone (no --file/--spec-json) changes only the description.
2641
+ if (description !== undefined && flag("file") === undefined && flag("spec-json") === undefined) return out({ schedule: updateScheduleDescription(ws(), needId(), description, io) });
2642
+ return out({ schedule: updateSchedule(ws(), needId(), withDescription(readSpec(), description), io) });
2643
+ }
2615
2644
  case "enable":
2616
2645
  case "disable": {
2617
2646
  const on = sub === "enable";
@@ -2654,7 +2683,7 @@ async function scheduleCmd() {
2654
2683
  if (op === "status") return out({ scheduler: schedulerStatus(ws(), io) });
2655
2684
  throw scheduleError("E_BAD_ARGS", "oats schedule host install|uninstall|status");
2656
2685
  }
2657
- default: throw scheduleError("E_BAD_ARGS", "usage: oats schedule list|show <id>|test <id>|add <id> --file <spec.json> [--workspace <member> --runs-on <host> --owner <host>/<login>]|update <id> --file <spec.json>|enable <id>|disable <id>|run <id> [--force]|remove <id> [--force]|reconcile <id> [--clear]|tick [--dry-run] [--host]|host install [--max-concurrent <N|default>] [--triggers-max-concurrent <N|none>]|uninstall|status [--dir <workspace>|--server <id>] [--json]");
2686
+ default: throw scheduleError("E_BAD_ARGS", "usage: oats schedule list|show <id>|test <id>|add <id> --file <spec.json> [--description=<text>] [--workspace <member> --runs-on <host> --owner <host>/<login>]|update <id> (--file <spec.json> [--description=<text>] | --description=<text>)|enable <id>|disable <id>|run <id> [--force]|remove <id> [--force]|reconcile <id> [--clear]|tick [--dry-run] [--host]|host install [--max-concurrent <N|default>] [--triggers-max-concurrent <N|none>]|uninstall|status [--dir <workspace>|--server <id>] [--json]");
2658
2687
  }
2659
2688
  } catch (e) {
2660
2689
  // K8b: typed refusal details travel (identity mismatch: key/declared; a refused file: its integrity source).
@@ -2673,7 +2702,7 @@ async function triggerCmd() {
2673
2702
  const ws = () => (scope ??= scheduleScopeOf(dirFlag()));
2674
2703
  const out = (result, text) => { if (JSON_MODE) jsonOk(result); else console.log(text ? text(result) : JSON.stringify(result, null, 2)); };
2675
2704
  const needId = () => { if (!id) throw T.triggerError("E_BAD_ARGS", `oats trigger ${sub} <id>`); return id; };
2676
- const usage = "usage: oats trigger add (--file <trigger.json> | --from <package>:<template> [--set <name>=<value>]… [--id <id>]) [--workspace <member> --runs-on <host> --owner <host>/<login>] | list | show <id> | enable <id> | disable <id> | remove <id> | test <id> | status [<id>] [--dir <deployment>] [--json] (<id>: local/<id> or <member>/<id>)";
2705
+ const usage = "usage: oats trigger add (--file <trigger.json> | --from <package>:<template> [--set <name>=<value>]… [--id <id>]) [--description=<text>] [--workspace <member> --runs-on <host> --owner <host>/<login>] | update <id> --description=<text> | list | show <id> | enable <id> | disable <id> | remove <id> | test <id> | status [<id>] [--dir <deployment>] [--json] (<id>: local/<id> or <member>/<id>)";
2677
2706
  const where = (t) => (t.origin?.kind === "workspace" ? (t.runsHere ? `runs here as ${t.owner}` : `${t.reason === "assigned-elsewhere" ? `runs on ${t.runsOn}` : t.reason ?? "disabled here"}`) : t.enabledHere ? "local" : "local, disabled");
2678
2707
  const line = (t) => `${t.id} ${where(t)} ${t.on?.source ?? "?"} ${t.on?.repo ?? "?"} [${(t.on?.events || []).join(",")}]${t.on?.labels?.length ? ` labels ${t.on.labels.join(",")}` : ""} every ${t.on?.poll ?? "?"} → spawn ${t.spawn?.soul ?? "?"}${t.spawn?.teams?.length ? ` in ${t.spawn.teams.join(",")}` : ""}${t.invalid ? ` INVALID: ${t.invalid.message}` : ""}`;
2679
2708
  // The workspace automations of this deployment (the snapshot) placed on this host.
@@ -2693,6 +2722,15 @@ async function triggerCmd() {
2693
2722
  actx = undefined;
2694
2723
  return out({ trigger: T.describeTrigger(ws(), id, ctx()) }, (r) => line(r.trigger));
2695
2724
  }
2725
+ case "update": {
2726
+ // Description only, for now (0.43.0): a full trigger update is remove + add.
2727
+ const known = new Set(["--description", "--dir", "--json"]);
2728
+ const other = args.slice(2).find((a) => a.startsWith("--") && !known.has(a));
2729
+ if (other) throw T.triggerError("E_BAD_ARGS", `oats trigger update: only --description is supported for now (got ${other}); to change anything else, remove the trigger and add it again`);
2730
+ const description = descriptionFlag();
2731
+ if (description === undefined) throw T.triggerError("E_BAD_ARGS", "oats trigger update <id> --description=<text> (--description= clears it)");
2732
+ return out({ trigger: T.updateTriggerDescription(ws(), needId(), description, ctx()) }, (r) => line(r.trigger));
2733
+ }
2696
2734
  case "remove": return out(T.removeTrigger(ws(), needId(), ctx()), (r) => `removed trigger ${r.removed}${r.live.length ? ` (its live instances keep running: ${r.live.join(", ")})` : ""}`);
2697
2735
  case "status": return out(T.triggerStatus(ws(), id, ctx()), (r) => r.triggers.map((t) => `${t.id} last poll ${t.lastPoll ? `${t.lastPoll.at} ${t.lastPoll.ok ? `ok (${t.lastPoll.matching}/${t.lastPoll.prs} PRs match)` : `FAILED: ${t.lastPoll.error}`}` : "never"} pending ${t.pending.length} fired ${t.firedTotal} live ${t.live.map((l) => l.instance).join(",") || "none"}${t.lastError ? `\n last error ${t.lastError.at}: ${t.lastError.message}` : ""}`).join("\n") || "(no triggers)");
2698
2736
  case "test": {
@@ -2727,6 +2765,7 @@ async function triggerCmd() {
2727
2765
  }
2728
2766
  const idFlag = flag("id");
2729
2767
  if (idFlag === true) throw T.triggerError("E_BAD_ARGS", "--id needs a trigger id");
2768
+ const description = descriptionFlag();
2730
2769
  let spec;
2731
2770
  if (file !== undefined) {
2732
2771
  if (file === true || !existsSync(file)) throw T.triggerError("E_BAD_ARGS", `--file ${file === true ? "needs a path" : `not found: ${file}`}`);
@@ -2739,12 +2778,14 @@ async function triggerCmd() {
2739
2778
  const kinds = await automationKinds(ws(), readLock(ws()), remoteOptionsFromEnv());
2740
2779
  const wid = typeof idFlag === "string" ? idFlag : from !== undefined ? String(from).split(":")[1] : spec?.id;
2741
2780
  if (typeof wid !== "string") throw T.triggerError("E_BAD_ARGS", "--workspace: name the trigger with --id (or an id in the file)");
2742
- const body = from !== undefined ? { from: String(from), ...(Object.keys(sets).length ? { set: sets } : {}) } : (({ id: _i, kind: _k, enabled: _e, template: _t, createdAt: _c, updatedAt: _u, ...rest }) => rest)(spec);
2743
- const r = await addWorkspaceAutomation(kinds.trigger, { id: wid, body });
2781
+ // A description is the file header's (the flag, else the file's), never the body's; made
2782
+ // from a template without one, the row shows the template's.
2783
+ const body = from !== undefined ? { from: String(from), ...(Object.keys(sets).length ? { set: sets } : {}) } : (({ id: _i, kind: _k, enabled: _e, template: _t, createdAt: _c, updatedAt: _u, description: _d, ...rest }) => rest)(spec);
2784
+ const r = await addWorkspaceAutomation(kinds.trigger, { id: wid, body, description: description ?? spec?.description });
2744
2785
  return out(r, printWorkspaceAdd);
2745
2786
  }
2746
2787
  if (spec === undefined) spec = await triggerFromPackage(T, String(from), sets, idFlag);
2747
- return out({ trigger: T.addTrigger(ws(), spec) }, (r) => `added ${line(r.trigger)}\n(\`oats trigger test ${r.trigger.id}\` checks gh, the repository, the soul and the teams on this host)`);
2788
+ return out({ trigger: T.addTrigger(ws(), withDescription(spec, description)) }, (r) => `added ${line(r.trigger)}\n(\`oats trigger test ${r.trigger.id}\` checks gh, the repository, the soul and the teams on this host)`);
2748
2789
  }
2749
2790
  default: throw T.triggerError("E_BAD_ARGS", usage);
2750
2791
  }
@@ -3267,7 +3308,7 @@ function versionCmd() {
3267
3308
  // Phase B: `instance-modules` and `spawn-provider-payload` are advertised only once spawn
3268
3309
  // runs on resolve/materialize (contract §6); a feature the binary does not implement is
3269
3310
  // never listed.
3270
- console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, harnesses: ["pi", "claude", "codex"], sessionBackends: ["tmux"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "session-start", "session-restart", "launch-config", "roster", "harvest", "schedule", "session-upload", "operations", "readiness", "instance-events", "instance-git", "lifecycle-plans"], features: ["retire-home", "session-start", "session-restart", "launch-config", "schedule", "schedule-host-caps", "session-upload", "operations", "instance-git", "instance-git-remote", "souls-declarations", "lifecycle-plans", "retire-retention", "readiness", "spawn-preview", "instance-events", "instance-events-2", "schedule-history", "schedule-read-2", "spawn-preview-2", "spawn-idempotency", "spawn-idempotency-2", "spawn-apply-2", "workspace-v2", "instance-modules", "spawn-provider-payload", "served-identity", "packages-no-approval", "spawn-name", "settings-origins", "team-model-3", "settings-declared", "capabilities-private", "layers-from", "harness", "package-souls", "triggers", "automations", "desktop-facts", "launch-preference", "preview-composed-from", "observe-max-age", "spawn-preview-max-age", "launch-config-default", "capability-show", "capture-file", "workspace-identity", "server-connect", "capability-route", "servers-per-workspace", "operator-default-soul", "waiting-on-you"], automationsApi: A.AUTOMATIONS_API, workspaceApi: 2, instanceGitApi: 1, spawnApplyApi: 1, soulsApi: 2, lifecycleApi: 1, readinessApi: 2, spawnPreviewApi: 2, eventsApi: 2, scheduleHistoryApi: 3, scheduleApi: SCHEDULE_API, operationsApi: 2, capabilityShowApi: 1 }));
3311
+ console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, harnesses: ["pi", "claude", "codex"], sessionBackends: ["tmux"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "session-start", "session-restart", "launch-config", "roster", "harvest", "schedule", "session-upload", "operations", "readiness", "instance-events", "instance-git", "lifecycle-plans"], features: ["retire-home", "session-start", "session-restart", "launch-config", "schedule", "schedule-host-caps", "session-upload", "operations", "instance-git", "instance-git-remote", "souls-declarations", "lifecycle-plans", "retire-retention", "readiness", "spawn-preview", "instance-events", "instance-events-2", "schedule-history", "schedule-read-2", "spawn-preview-2", "spawn-idempotency", "spawn-idempotency-2", "spawn-apply-2", "workspace-v2", "instance-modules", "spawn-provider-payload", "served-identity", "packages-no-approval", "spawn-name", "settings-origins", "team-model-3", "settings-declared", "capabilities-private", "layers-from", "harness", "package-souls", "triggers", "automations", "desktop-facts", "launch-preference", "preview-composed-from", "observe-max-age", "spawn-preview-max-age", "launch-config-default", "capability-show", "capture-file", "workspace-identity", "server-connect", "capability-route", "servers-per-workspace", "operator-default-soul", "waiting-on-you", "automation-descriptions"], automationsApi: A.AUTOMATIONS_API, workspaceApi: 2, instanceGitApi: 1, spawnApplyApi: 1, soulsApi: 2, lifecycleApi: 1, readinessApi: 2, spawnPreviewApi: 2, eventsApi: 2, scheduleHistoryApi: 3, scheduleApi: SCHEDULE_API, operationsApi: 2, capabilityShowApi: 1 }));
3271
3312
  return;
3272
3313
  }
3273
3314
  console.log(`@awebai/oats ${OATS_VERSION} (desktop API v1)`);
@@ -3571,6 +3612,8 @@ async function serverRouteCmd() {
3571
3612
  rest.push("--spec-json", readFileSync(f, "utf8"));
3572
3613
  continue;
3573
3614
  }
3615
+ // An inline --description travels inline (it may be empty, or start with --).
3616
+ if (a === "--description" && inlineValues.has("description")) { rest.push(`--description=${inlineValues.get("description")}`); continue; }
3574
3617
  rest.push(a);
3575
3618
  }
3576
3619
  let out;
@@ -3714,6 +3757,7 @@ async function serverRouteCmd() {
3714
3757
  }
3715
3758
  console.log(`Retired ${r.retired} on ${id}${r.deferred ? " (deferred completion scheduled there)" : ""}${r.rollbackIncomplete ? " — cleanup INCOMPLETE on the server, home retained there" : ""}`);
3716
3759
  for (const line of workRecoveryLines(r, { host: target.sshHost })) console.log(line);
3760
+ for (const line of extraWorktreeLines(r, { host: target.sshHost })) console.log(line);
3717
3761
  if (r.rollbackIncomplete) {
3718
3762
  for (const f of r.rollbackIncomplete) console.error(` ${f}`);
3719
3763
  const branch = failedSpawnBranchLine(r, r.rollbackIncomplete, { host: target.sshHost });
@@ -3942,10 +3986,14 @@ Usage:
3942
3986
  host install|uninstall|status explicit IANA tz; see docs/schedules.md); --server
3943
3987
  routes to that host's workspace
3944
3988
  install [--max-concurrent N|default] [--triggers-max-concurrent N|none]
3989
+ oats schedule add|update <id> … --description=<text> a one-line summary (1-200 characters)
3990
+ oats schedule update <id> --description=<text> change only it (--description= clears it)
3945
3991
  oats trigger add (--file <json> | --from <package>:<template> [--set k=v]) | list | show | enable
3946
3992
  | disable | remove <id> | test <id> | status [<id>] event-driven spawns (github.pull_request
3947
3993
  polled with the host's gh by the schedule tick;
3948
3994
  see docs/schedules.md#triggers)
3995
+ oats trigger add … --description=<text> a one-line summary (1-200 characters)
3996
+ oats trigger update <id> --description=<text> change only it (--description= clears it)
3949
3997
  oats trigger|schedule add … --workspace <member> --runs-on <host> --owner <host>/<login>
3950
3998
  a trigger/schedule declared in Git (<member>/<id>):
3951
3999
  writes oats-triggers|oats-schedules/<id>.yaml in a
@@ -108,6 +108,7 @@ canonical (`github.com/<org>/<repo>`, or `local/<abs-path>`). Examples use
108
108
  | `capability-route` | `oats <namespace> <command> … --server <id>` runs the capability command on the server, OATS 0.39.0 ([Capability commands on a server](#capability-commands-on-a-server)) | |
109
109
  | `operator-default-soul` | a capability command from a deployment without `--soul` runs as the first soul that provides its namespace (named on stderr); none is `E_BAD_ARGS`, OATS 0.39.0 ([capabilities.md](capabilities.md)) | |
110
110
  | `waiting-on-you` | the `waiting` event kind and the session boundary rule; `oats instance waiting` and `oats instance attention`; `waitingOnYou` (with `message`) on `oats status --json` instance rows, on `oats session inspect --json` and in the events read, OATS 0.40.0 ([Waiting on you](#waiting-on-you)) | `eventsApi: 2` |
111
+ | `automation-descriptions` | a `description` on every trigger and schedule row, local or workspace, by one rule; `oats schedule update <id> --description=<text>` (the description only) and `oats trigger update <id> --description=<text>`; `--description=<text>` on `schedule add` and `trigger add`, OATS 0.43.0 ([Shared row fields](#automations-shared-rows)) | |
111
112
 
112
113
  Payload-only integers, never in the probe: `onboardApi: 2`, `syncApi: 1`,
113
114
  `workspaceStatusApi: 1`, `capabilitiesApi: 1`, the `oats souls` document's
@@ -1923,7 +1924,11 @@ route target:
1923
1924
  "running":true,"identity":{"alias":"dev-a","address":"acme/dev-a"},"identityAddress":"acme/dev-a",
1924
1925
  "teams":[{"label":"default","team":"acme:team"}],"startedAt":"2026-09-29T10:00:00.000Z","createdAt":"2026-09-29T09:58:12.004Z",
1925
1926
  "model":"opus","runtimeState":null,"parentInstance":"lead","siblingInstance":null,"relation":"child","relativeTo":"lead",
1926
- "spawnOrigin":"instance","retirePending":false,"rollbackIncomplete":false,
1927
+ "spawnOrigin":"instance","work":"worktree","repo":"/srv/team/ws","branch":"agents/dev-a","modelFrom":"soul",
1928
+ "soul":{"repoKey":"github.com/acme/team","commit":"66566512…","current":"9c1e04ab…","status":"moved"},
1929
+ "modules":[{"name":"oats.okf","from":{"kind":"package","package":"oats.okf","version":"2.1.3","commit":"ab897841…","integrity":"sha256-bada35…","repoKey":"github.com/awebai/oats-okf"},
1930
+ "commit":"ab897841…","current":{"commit":"ab897841…","version":"2.1.3"},"status":"current"}],
1931
+ "retirePending":false,"rollbackIncomplete":false,
1927
1932
  "savedRoute":false,"addressable":true,"missingRemotely":false}],
1928
1933
  "retireFailures":[]}
1929
1934
  ```
@@ -1940,9 +1945,16 @@ route target:
1940
1945
  - **Instance rows** relay the host's own `status --json` row: `identity`,
1941
1946
  `identityAddress`, `teams`, `startedAt`, `createdAt`, `model`,
1942
1947
  `runtimeState`, `parentInstance`, `siblingInstance`, `relation`,
1943
- `relativeTo` and `spawnOrigin` are always present, `null` when the host
1944
- does not supply them (a host before 0.31, a fact it never recorded, or a
1945
- saved route the host no longer lists). Nothing is derived on this side.
1948
+ `relativeTo` and `spawnOrigin`, and (relayed from 0.42.1) `work`, `repo`,
1949
+ `branch`, `modelFrom`, `soul` and `modules`, are always present, `null`
1950
+ when the host does not supply them (an older host, a fact it never
1951
+ recorded, or a saved route the host no longer lists). Each is the
1952
+ [local row's](#the-roster-oats-status---json) fact of the same name,
1953
+ relayed as the host answered it. Nothing is derived on this side: `repo`
1954
+ is a path on the host, never read here, and `soul` and `modules` are the
1955
+ host's own drift observation (against its own members and lock), not
1956
+ recomputed: `modules` is the drift rows, or the recorded map when the host
1957
+ could not read its workspace, as on a local row.
1946
1958
  - **`waitingOnYou`** (0.40.2, [Waiting on you](#waiting-on-you)) is on a row
1947
1959
  only when the host's kernel reports it: a row from a host before 0.40.0,
1948
1960
  and a saved route the host did not list, have no such key. Absent means
@@ -2219,6 +2231,15 @@ oats instance events <instance> [--limit <n>] [--since <iso>] [--home <abs>] [--
2219
2231
  `--delete-branch` (since 0.41.0: `this self-retire was requested with
2220
2232
  --delete-branch by an older OATS; retirement no longer deletes branches, so
2221
2233
  the branch and the worktree were left`).
2234
+ - `worktree-retained` (`data: {movedTo, branch, recordedBranch}`) and
2235
+ `worktree-removed` (`data: {branch}`) are written to the workspace log only,
2236
+ by the retire's worktree step. Since 0.42.1 the retire also writes one per
2237
+ extra tree it handled
2238
+ ([extra trees at retire](souls-and-instances.md#extra-trees-at-retire)),
2239
+ with `extra: true` and the tree's absolute `path` added:
2240
+ `worktree-retained` `{movedTo, branch, recordedBranch: null, extra: true,
2241
+ path}` and `worktree-removed` `{branch, extra: true, path}`. A row without
2242
+ `extra` is about `work/`.
2222
2243
  - **Incarnation.** Each row carries the writing home's `createdAt` (or
2223
2244
  `null` for old rows); the top-level `incarnation` is the current home's (or
2224
2245
  `null`). Earlier incarnations are returned as this address's history.
@@ -2438,7 +2459,9 @@ oats retire <instance> --plan [--home <abs>] [--dir <d>] --json
2438
2459
  "upstream":{"ref":null,"ahead":null,"behind":null},"base":{"ref":null,"ahead":null,"behind":null},"remote":null},
2439
2460
  "workMode":"worktree","repo":"/w/one","recordedBranch":"agents/dev-1",
2440
2461
  "children":[{"instance":"dev-1-child","agent":"dev","home":"/w/agents/dev/instances/dev-1-child","session":{"state":"shell","present":true,"backend":"tmux","established":true}}],
2441
- "ambiguous":[],"pullRequest":"unknown"},
2462
+ "ambiguous":[],"pullRequest":"unknown",
2463
+ "extraWorktrees":[{"path":"/w/agents/dev/instances/dev-1/.work-docs","repo":"/w/docs","branch":"agents/dev-1-docs","detachedAt":null,
2464
+ "disposition":"retain","movedTo":"/w/.agents/worktrees/docs/agents-dev-1-docs","reason":"…"}]},
2442
2465
  "defaults":{"retainWorktree":true,"deleteBranch":false,"stopChildren":true,"retainChildren":true},
2443
2466
  "planRevision":"4e5f6a7b8c9d0e1f2a3b4c5d","notes":["the worktree is on feat/x, not the recorded agents/dev-1; …"]}
2444
2467
  ```
@@ -2476,10 +2499,35 @@ oats retire <instance> --plan [--home <abs>] [--dir <d>] --json
2476
2499
  there when it is not empty` in directory mode, and absent in checkout,
2477
2500
  attached and workspace modes. They are strings in `notes`: no other key
2478
2501
  changes, and `planRevision` is unaffected.
2502
+ - `facts.extraWorktrees` (0.42.1) lists the instance's
2503
+ [extra trees](souls-and-instances.md#extra-trees-at-retire): linked
2504
+ worktrees at `<home>/.work-*` that Git confirms. It is an array, empty when
2505
+ there are none, in every work mode. Each row is `{path, repo, branch,
2506
+ detachedAt, disposition, movedTo, reason}`:
2507
+ - `path`: the tree's absolute path in the home.
2508
+ - `repo`: its repository, the first entry of that repository's `git
2509
+ worktree list` (the main worktree, or the bare repository).
2510
+ - `branch`: the branch its HEAD is on, or `null`. `detachedAt`: the commit
2511
+ when HEAD is detached, else `null`.
2512
+ - `disposition`: `"remove"` (the tree is clean: it would be removed, its
2513
+ branch kept), `"retain"` (it would be moved to `movedTo`, as `work/` is
2514
+ retained) or `"refuse"` (the retire would refuse with
2515
+ `E_WORK_PRESERVATION_FAILED` and keep the home).
2516
+ - `movedTo`: the target for `"retain"`, else `null`.
2517
+ - `reason`: `null` for `"remove"`, why the tree is not clean for
2518
+ `"retain"`, why it is refused for `"refuse"`.
2519
+
2520
+ `notes` also carries one string per tree that says the same. The trees are
2521
+ part of `planRevision`: a tree created, removed, dirtied or cleaned between
2522
+ the plan and the apply, or a change of its disposition or target (another
2523
+ directory taking the `<leaf>-N` it would move to, for example), refuses a
2524
+ guarded apply with `E_PLAN_STALE` before anything runs. A reader that does
2525
+ not know the key can ignore it; the `notes` strings say the same.
2479
2526
 
2480
2527
  Plain `retire` keeps a worktree-mode instance's work: the worktree is moved
2481
2528
  (`git worktree move`) to `<deployment>/.agents/worktrees/<repo>/<branch>` (a
2482
- `-2` suffix if taken; `detached-<oid12>` when detached), state intact.
2529
+ `-2` suffix if taken; `detached-<oid12>` when detached), state intact. An
2530
+ extra tree that is not clean is moved the same way; a clean one is removed.
2483
2531
 
2484
2532
  ```text
2485
2533
  oats retire <instance> [--plan-revision <rev> --idempotency-key <key>] [--discard-worktree] [--home <abs>] --json
@@ -2535,6 +2583,21 @@ A first retire prints the **raw receipt**, not an envelope:
2535
2583
  retire (hooks run, a recovery copied), and neither says that one happened:
2536
2584
  an error here is not proof that nothing happened, nor that a recovery
2537
2585
  exists.
2586
+ - `extraWorktrees` (0.42.1): the extra trees the retire handled, present only
2587
+ when it handled at least one. Each row is the plan's row (`{path, repo,
2588
+ branch, detachedAt, disposition, movedTo, reason}`) plus `outcome`:
2589
+ `"removed"` or `"retained"`, with `movedTo` where a retained tree went. The
2590
+ step runs only when the home is removed (not with `--keep-dir`, not when the
2591
+ home is kept for a retry), after the hooks and before the worktree step of
2592
+ `work/`. `--discard-worktree` does not apply to it. A locked tree
2593
+ (`"refuse"` in the plan) stops the retire with `E_WORK_PRESERVATION_FAILED`
2594
+ naming the tree before anything runs (no session stop, no retire hook);
2595
+ a lock that appears during the hooks, or a move or removal Git refuses,
2596
+ stops it at the step, after the hooks. `--force` does not bypass either;
2597
+ the home and `work/` are kept, and trees already handled stay handled. A tree
2598
+ that no longer matches what the applied plan said refuses with
2599
+ `E_PLAN_STALE`, the home kept, rather than be moved or removed unplanned.
2600
+ The key is additive.
2538
2601
  - `workRecovery`: `{path, classes, bytes, home, outputs?, repoCopy?,
2539
2602
  notCopied?, afterHooks?}`, present when a recovery was written. One retire
2540
2603
  writes at most one recovery directory, and `path` is that directory.
@@ -2560,10 +2623,12 @@ A first retire prints the **raw receipt**, not an envelope:
2560
2623
  names: a prefix that matches several entries lists each one, and a
2561
2624
  declared entry that does not exist is not listed. `owner` is the
2562
2625
  declaring capability; when two declare the same entry it is the first in
2563
- capability-name order. Each entry has exactly these three keys: names and
2564
- owners only, no sizes, hashes, modes or contents of what was left out.
2565
- `scope` is always `"home"` in this release. Present only when there is
2566
- at least one.
2626
+ capability-name order. Since 0.42.1 the list also holds each verified
2627
+ extra tree (`.work-<purpose>`), with `owner: "kernel:extra-worktree"`: the
2628
+ retire handles it at its own step, never in the copy. Each entry has
2629
+ exactly these three keys: names and owners only, no sizes, hashes, modes
2630
+ or contents of what was left out. `scope` is always `"home"` in this
2631
+ release. Present only when there is at least one.
2567
2632
  - `afterHooks`: `{home: boolean, work: boolean}`, saying which parts were
2568
2633
  copied again under `after-hooks/`: `after-hooks/home/` when a retire hook
2569
2634
  changed the home (its bytes and permission bits, the kernel's own
@@ -2615,8 +2680,9 @@ A first retire prints the **raw receipt**, not an envelope:
2615
2680
  `idempotencyKey` and `replayed: false`.
2616
2681
 
2617
2682
  Refusals (envelopes): `E_PLAN_STALE`, `E_CHILDREN_RUNNING`,
2618
- `E_WORK_PRESERVATION_FAILED` (the home is kept; retry or
2619
- `--discard-worktree`), `E_WORK_INSPECTION_FAILED` (the home is kept; the
2683
+ `E_WORK_PRESERVATION_FAILED` (the home is kept; retry, or
2684
+ `--discard-worktree` when it is `work/` that could not be re-homed: it does
2685
+ not apply to an extra tree), `E_WORK_INSPECTION_FAILED` (the home is kept; the
2620
2686
  message names the entry or the state that could not be read),
2621
2687
  `E_SESSION_UNKNOWN`, `E_AMBIGUOUS_INSTANCE`,
2622
2688
  `E_NO_ROOT`, `E_LIFECYCLE_FAILED`. A recovery whose Git status disagrees with
@@ -2797,6 +2863,7 @@ workspace item of a readable member. Render the rows; never re-derive them.
2797
2863
  ```text
2798
2864
  oats schedule list [--dir <d>] --json
2799
2865
  oats schedule show <id> --json
2866
+ oats schedule update <id> --description=<text> [--dir <d>] --json
2800
2867
  ```
2801
2868
 
2802
2869
  ```json
@@ -2823,6 +2890,14 @@ oats schedule show <id> --json
2823
2890
  - `list`: `{scope, scheduleApi, scheduleHistoryApi, integrity, host,
2824
2891
  triggers, snapshot, schedules, scheduler}`; `triggers` counts the trigger
2825
2892
  definitions left out. `show <id>`: `{schedule}`, without `integrity`.
2893
+ - `update <id> --description=<text>` without `--file`/`--spec-json` (feature
2894
+ `automation-descriptions`) changes only a local schedule's description,
2895
+ any kind's (a command schedule too), and is allowed while the job runs or
2896
+ has an unresolved attempt. `--description=` (empty) removes it. It answers
2897
+ `{schedule}` (the row `show` answers). A workspace schedule refuses
2898
+ `E_AUTOMATION_WORKSPACE`; out of the rule is `E_SCHEDULE_INVALID {field:
2899
+ "description"}`. Run it as argv, without a shell, always in the `=` form.
2900
+ An older kernel refuses it as a missing `--file`.
2826
2901
  - **A readable row**: the definition (`id, kind, cron, tz, enabled, …`, and
2827
2902
  `agent/task/purpose/harness` for a spawn, `argv/cwd` for a command, the
2828
2903
  message for a wake, the operation for an operation) plus `scope,
@@ -2842,14 +2917,8 @@ oats schedule show <id> --json
2842
2917
  limit. Desktop accepts these schedule names for creation, editing and row
2843
2918
  actions (enable, disable, test, run and reconcile), including qualified IDs.
2844
2919
  A spawn's derived instance name still has a 64-character limit.
2845
- - **`description`** (0.40.0): the shared row field is the local definition's
2846
- `description` when it has one, else `null` (a workspace schedule's comes
2847
- from its file header). It is one line of at most 200 characters with no
2848
- control characters; show it in place of the argv when present. Like `task`,
2849
- it is untrusted text: render it as text. Desktop preserves it unchanged
2850
- through edits to timing and other fields. A kernel before 0.40.0 sends
2851
- `null` for every local schedule, and drops a `description` given to `oats
2852
- schedule add` without refusing it.
2920
+ - **`description`**: the [shared row field](#automations-shared-rows).
2921
+ Desktop preserves it unchanged through edits to timing and other fields.
2853
2922
  - **An unreadable row** (`list` only): `{id, scope, scheduleApi,
2854
2923
  scheduleHistoryApi, unreadable: {code, message}, history: {status:
2855
2924
  "corrupt", stored: null, truncated: false}, recentRuns: []}`. One bad job
@@ -2899,14 +2968,14 @@ oats schedule show <id> --json
2899
2968
  ### `oats trigger`
2900
2969
 
2901
2970
  ```text
2902
- oats trigger list | show <id> | status [<id>] | test <id> | add (--file <json> | --from <package>:<template> [--set k=v]) | enable <id> | disable <id> | remove <id> --json
2971
+ oats trigger list | show <id> | status [<id>] | test <id> | add (--file <json> | --from <package>:<template> [--set k=v]) [--description=<text>] | update <id> --description=<text> | enable <id> | disable <id> | remove <id> --json
2903
2972
  ```
2904
2973
 
2905
2974
  Event-driven spawns. Local definitions live in `oats-schedules.json` (`kind:
2906
2975
  "trigger"`).
2907
2976
 
2908
2977
  - `list`: `{triggerApi, scope, host, snapshot, triggers, scheduler}`.
2909
- `show`, `add`, `enable`, `disable` → `{trigger}`; `remove` → `{removed,
2978
+ `show`, `add`, `update`, `enable`, `disable` → `{trigger}`; `remove` → `{removed,
2910
2979
  live: [instance]}`. A stored definition that no longer validates carries
2911
2980
  `invalid: {code, message, field?}`.
2912
2981
  - **A trigger row**: the [shared row fields](#automations-shared-rows) plus
@@ -2942,6 +3011,12 @@ Event-driven spawns. Local definitions live in `oats-schedules.json` (`kind:
2942
3011
  cannot reach is a warning.
2943
3012
  - A triggered instance records `instance.json.trigger`; its event file is
2944
3013
  `OATS_TRIGGER_EVENT_FILE`.
3014
+ - `update <id> --description=<text>` (feature `automation-descriptions`)
3015
+ changes only a local trigger's description; `--description=` (empty)
3016
+ removes it. It sets `updatedAt` and leaves fired and pending events
3017
+ untouched. `<id>` is `local/<id>` or the bare id; a workspace trigger
3018
+ refuses `E_AUTOMATION_WORKSPACE`. Any other flag is `E_BAD_ARGS` ("only
3019
+ --description is supported for now"). An older kernel has no `update`.
2945
3020
  - Errors: `E_TRIGGER_INVALID {field}`, `E_TRIGGER_EXISTS`,
2946
3021
  `E_TRIGGER_UNKNOWN`, `E_TRIGGER_TEAMS`, `E_TRIGGER_POLL`,
2947
3022
  `E_TRIGGER_FAILED`, `E_BAD_ARGS`, `E_PACKAGE_MISSING`,
@@ -2967,7 +3042,18 @@ registered). `maxConcurrent` is the effective scheduled-job cap (default 5);
2967
3042
  localPath}` or `{kind: "workspace", repoKey, path, commit, url,
2968
3043
  localPath}` (`url` for `github.com` only; `localPath` `null` when the
2969
3044
  member is not cloned here).
2970
- - `description`, `owner`, `runsOn`, `runsHere`, `reason` (`null` |
3045
+ - `description`: what it is for, in words, or `null`. One rule for both kinds
3046
+ and every level: one line of 1 to 200 characters with no control
3047
+ characters (no `\p{Cc}`, U+2028 or U+2029). A local item's comes from its
3048
+ definition; a workspace item's from its file header, and for a trigger made
3049
+ `from:` a package template, the template's when the header has none. A
3050
+ header out of the rule counts as none (the item still runs) and leaves a
3051
+ snapshot problem at `<path>#/description`. Like `task`, it is untrusted
3052
+ text: render it as text. A kernel before 0.43.0 (feature
3053
+ `automation-descriptions`) sends `null` for every local trigger and refuses
3054
+ a trigger `description`; one before 0.40.0 sends `null` for every local
3055
+ schedule too.
3056
+ - `owner`, `runsOn`, `runsHere`, `reason` (`null` |
2971
3057
  `host-unnamed` | `assigned-elsewhere` | `owner-mismatch` | `untrusted`),
2972
3058
  `reasonDetail`, `enabledHere`.
2973
3059
  - `untrusted` (0.30, [automations.trust](configuration.md#who-runs-workspace-automations)):
package/docs/desktop.md CHANGED
@@ -211,6 +211,17 @@ whether the host scheduler is enabled. Pause, edit, run now and delete operate
211
211
  on that workspace's saved jobs. Launching an agent is reported separately from
212
212
  the end of its run; neither means its task succeeded.
213
213
 
214
+ Each schedule and trigger can carry a one-line **summary** (up to 200
215
+ characters), shown under its name in the list. Without one, the list shows the
216
+ first line of the task or wake message in italics, or "No summary" for a
217
+ command or operation. Set it in the schedule form's Summary field, or with
218
+ **Edit summary** on any local schedule or trigger (leave it empty to remove
219
+ it); a workspace item's summary is its file's `description:` in Git. A detail
220
+ page shows everything the item sends (the whole prompt or message, a command's
221
+ arguments as written, an operation and its home), its run state and where it
222
+ comes from. Writing summaries needs OATS 0.43 or later; an older OATS still
223
+ shows the ones it reports.
224
+
214
225
  The Spawn dialog also has an optional **Recurring wake-up** setting. It binds
215
226
  the schedule to the newly created home, preserving that agent's identity and
216
227
  work. A wake starts that same home if it is stopped, then sends the saved
@@ -249,14 +260,18 @@ for a password or key, and never runs anything over ssh itself.
249
260
 
250
261
  Every instance a registered server reports shows in its workspace's roster, whoever spawned it. You
251
262
  can open its terminal, start, restart, stop and remove it, and read its readiness, activity, Git
252
- and diffs, as for a local one. Only its pull request is not read here, since the forge reads this
263
+ and diffs, as for a local one. The context panel's Soul tab and its Messaging & Teams list read
264
+ through the server too (`oats inspect --server <id> --home <path>`), and its Work card, "model
265
+ from" line and "older build" chip show the facts the server relays. Only its pull request is not read here, since the forge reads this
253
266
  computer's clones. Every command goes to the server by the instance's home (`--server <id> --home
254
267
  <path>`), never by a bare name. Stop and Remove show the plan the server makes, and confirm
255
268
  against it.
256
269
 
257
270
  A read waits for the server: the view says "Reading from <server>…", and gives up after about
258
271
  45 seconds with "Couldn't reach <server>." When the server refuses, you see its code and message;
259
- nothing is read from this computer in its place. A row that can't be opened says why on the row:
272
+ nothing is read from this computer in its place. When the server's OATS, or this computer's, lacks
273
+ what a part of the panel needs, that part names the server and says which OATS to update, rather
274
+ than showing nothing. A row that can't be opened says why on the row:
260
275
  Herdr no longer supported, gone from <server>, not reachable on <server>, or <server> not reached.
261
276
  For an instance a server no longer lists, the reason names the command that removes it from this
262
277
  computer (`oats server forget <server> --instance <name>`).
package/docs/packages.md CHANGED
@@ -283,6 +283,12 @@ A package may also ship **trigger templates**: `triggers: [{ id,
283
283
  file }]` in `oats-package.json`, each file `{ parameters, definition }`.
284
284
  `oats trigger add --from <package>:<id> --set <name>=<value>` instantiates one
285
285
  at the locked commit; see [schedules.md#triggers](schedules.md#triggers).
286
+ A template's `definition` may carry `description`, the one-line summary its
287
+ triggers show (validated like any trigger's: one line of 1 to 200 characters
288
+ without control characters). `add --from` copies it and `--description=<text>`
289
+ overrides it; a workspace trigger made `from:` it shows its file header's
290
+ description, else the template's. A kernel before 0.43.0 refuses the key, so
291
+ a package whose template sets it needs `compatibility.oats: ">=0.43.0"`.
286
292
 
287
293
  ## Compatibility floors
288
294