@opvs-ai/cli 0.14.0 → 0.15.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.
@@ -3,6 +3,149 @@
3
3
  */
4
4
  import { getEffectiveConfig, createClient } from "@opvs-ai/core";
5
5
  import { buildUrl, printResponse, printJson } from "../lib/format.js";
6
+ import { confirm } from "../lib/confirm.js";
7
+ import { isUuid } from "../lib/board-refs.js";
8
+ import { healthParams, renderHealthReport, withHealthOptions } from "../lib/health.js";
9
+ import { buildRoutingBody, withRoutingOptions } from "../lib/routing.js";
10
+ import { FACTORY_PROJECT_GATE, warnIfFactoryUnresolved } from "../lib/factory-gate.js";
11
+ import { boolFlag, oneOf } from "../lib/options.js";
12
+ import { readFileSync } from "node:fs";
13
+ /** `BoardCreate.mode` pattern (agentboard `schemas/board.py`). */
14
+ const BOARD_MODES = ["pipeline", "evergreen"];
15
+ /** `BoardUpdate.status` pattern. */
16
+ const BOARD_STATUSES = ["active", "completed"];
17
+ // `graph_service` is the only reader of `mode` (7-day throughput for evergreen).
18
+ const MODE_HELP = "pipeline | evergreen. A label for how the board runs: a pipeline has an end, an evergreen board runs continuously " +
19
+ "and the board graph shows its 7-day throughput. Nothing else reads it.";
20
+ // Nothing but the response builders reads `boards.status` — no claim, move or list filter.
21
+ const STATUS_HELP = "active | completed. A LABEL (O-B1): completing a board moves no card, and its cards stay claimable.";
22
+ // The gateway list (`board.py` list_boards) filters `is_archived = false`; the
23
+ // claim engine does not check it.
24
+ const ARCHIVED_HELP = "true | false. An archived board drops out of `opvs boards list` but stays reachable by id, and its cards stay " +
25
+ "CLAIMABLE — archiving does not stop a factory.";
26
+ // `field_visibility.py`: a key absent from field_config is PUBLIC.
27
+ const FIELD_CONFIG_HELP = "Custom field definitions as a JSON array of {key,label,type,visible,position,group,options,visibility}. " +
28
+ "REPLACES the whole list. A field you leave out loses its visibility tier — an owner_only or internal field " +
29
+ "becomes readable by everyone who can see the board. Read the current list with `opvs boards get` first.";
30
+ const BOARD_TYPE_FACTORY_NOTE = ` ${FACTORY_PROJECT_GATE}`;
31
+ /**
32
+ * `--field-config <json>` / `--field-config-file <path>` → the array the PATCH
33
+ * sends. Exactly one; an object or a scalar exits here rather than 422ing with a
34
+ * pydantic path the operator did not type.
35
+ */
36
+ function readFieldConfig(opts) {
37
+ if (opts.fieldConfig === undefined && opts.fieldConfigFile === undefined)
38
+ return undefined;
39
+ if (opts.fieldConfig !== undefined && opts.fieldConfigFile !== undefined) {
40
+ console.error("Error: pass --field-config or --field-config-file, not both");
41
+ process.exit(1);
42
+ }
43
+ const raw = opts.fieldConfig ?? readFileSync(opts.fieldConfigFile, "utf-8");
44
+ let parsed;
45
+ try {
46
+ parsed = JSON.parse(raw);
47
+ }
48
+ catch {
49
+ console.error("Error: field config must be valid JSON (an array of field definitions)");
50
+ process.exit(1);
51
+ }
52
+ if (!Array.isArray(parsed)) {
53
+ console.error("Error: field config must be a JSON ARRAY of field definitions — it replaces the whole list");
54
+ process.exit(1);
55
+ }
56
+ return parsed;
57
+ }
58
+ function fmtProject(p) {
59
+ return `${p.name} (${p.id})${p.is_archived ? " [archived]" : ""}`;
60
+ }
61
+ function fmtValue(v) {
62
+ if (v === null || v === undefined)
63
+ return "—";
64
+ if (Array.isArray(v))
65
+ return `[${v.join(",")}]`;
66
+ return String(v);
67
+ }
68
+ /**
69
+ * Print an apply-preset plan: one line per column with the card count it puts
70
+ * in scope, then conflicts and the columns the preset leaves alone. The card
71
+ * count is the number an operator reads to decide whether to apply, so it is
72
+ * on every row rather than only in the summary.
73
+ */
74
+ export function renderPresetPlan(plan) {
75
+ const lines = [];
76
+ lines.push(`Preset ${plan.preset ?? "?"} on a ${plan.board_type ?? "?"} board — ${plan.applied ? "APPLIED" : "DRY RUN, nothing written"}`);
77
+ lines.push("");
78
+ for (const c of plan.columns ?? []) {
79
+ const cards = `${c.cards_affected ?? 0} card(s)`;
80
+ lines.push(` ${c.action.toUpperCase().padEnd(9)} ${c.slug.padEnd(16)} ${cards.padStart(10)} ${c.name}`);
81
+ for (const [field, pair] of Object.entries(c.changes ?? {})) {
82
+ const [before, after] = pair ?? [];
83
+ lines.push(` ${"".padEnd(9)} ${field}: ${fmtValue(before)} → ${fmtValue(after)}`);
84
+ }
85
+ for (const n of c.notes ?? [])
86
+ lines.push(` ${"".padEnd(9)} note: ${n}`);
87
+ }
88
+ const extra = plan.extra_columns ?? [];
89
+ if (extra.length) {
90
+ lines.push("");
91
+ lines.push(`Left in place (not in the preset, cards untouched): ${extra.join(", ")}`);
92
+ }
93
+ const conflicts = plan.conflicts ?? [];
94
+ if (conflicts.length) {
95
+ lines.push("");
96
+ lines.push("Conflicts:");
97
+ for (const k of conflicts) {
98
+ lines.push(` ${k.blocking === false ? "NON-BLOCKING" : "BLOCKING"} ${k.slug} ${k.code} — ${k.detail}`);
99
+ }
100
+ }
101
+ const s = plan.summary ?? {};
102
+ lines.push("");
103
+ lines.push(`Summary: ${s.created ?? 0} created · ${s.updated ?? 0} updated · ${s.unchanged ?? 0} unchanged · ` +
104
+ `${s.cards_affected ?? 0} card(s) affected. No card is moved or deleted.`);
105
+ return lines.join("\n");
106
+ }
107
+ /** Stable, key-order-independent rendering of a column's `changes` map. */
108
+ function canonicalChanges(changes) {
109
+ const c = changes ?? {};
110
+ return JSON.stringify(Object.keys(c).sort().map((k) => [k, c[k]]));
111
+ }
112
+ /**
113
+ * The columns whose planned change differs between the dry run the operator saw
114
+ * and the plan the server actually applied (ASP-118 F14).
115
+ *
116
+ * The apply is a SECOND request with no plan token or version check
117
+ * (`board_preset_service.py`), so another session can change the columns in
118
+ * between — and then a different set of rebinds and card counts is written
119
+ * than the one on screen. Same shape as `move-to-project`'s `removed_from`
120
+ * comparison: the server's answer is the truth, and a difference is reported.
121
+ *
122
+ * @returns one line per differing column slug; empty when they match.
123
+ */
124
+ export function presetPlanDrift(preview, applied) {
125
+ const index = (plan) => new Map((plan.columns ?? []).map((c) => [c.slug, c]));
126
+ const before = index(preview);
127
+ const after = index(applied);
128
+ const out = [];
129
+ for (const slug of [...new Set([...before.keys(), ...after.keys()])].sort()) {
130
+ const b = before.get(slug);
131
+ const a = after.get(slug);
132
+ if (!b || !a) {
133
+ out.push(`${slug}: ${b ? "in the preview, not in what was applied" : "applied, but not in the preview"}`);
134
+ continue;
135
+ }
136
+ const diffs = [];
137
+ if (b.action !== a.action)
138
+ diffs.push(`action ${b.action} → ${a.action}`);
139
+ if ((b.cards_affected ?? 0) !== (a.cards_affected ?? 0)) {
140
+ diffs.push(`cards ${b.cards_affected ?? 0} → ${a.cards_affected ?? 0}`);
141
+ }
142
+ if (canonicalChanges(b.changes) !== canonicalChanges(a.changes))
143
+ diffs.push("field changes differ");
144
+ if (diffs.length)
145
+ out.push(`${slug}: ${diffs.join(", ")}`);
146
+ }
147
+ return out;
148
+ }
6
149
  export function registerBoardCommands(program) {
7
150
  const boards = program.command("boards").description("AgentBoard management");
8
151
  boards
@@ -51,9 +194,21 @@ export function registerBoardCommands(program) {
51
194
  .requiredOption("-n, --name <name>", "Board name")
52
195
  .option("-d, --description <desc>", "Board description")
53
196
  .option("--visibility <vis>", "Visibility: public or private", "public")
54
- .option("--board-type <type>", "Board type: tasks, emails, contacts, companies, deals, content_calendar, factory")
197
+ .option("--board-type <type>", `Board type: tasks, emails, contacts, companies, deals, content_calendar, factory.${BOARD_TYPE_FACTORY_NOTE} ` +
198
+ "A new board is in no project.")
199
+ // ASP-113: "the fully claim-wired pipeline" was true of the COLUMNS and
200
+ // false of the board — ORP-6 refuses every claim until the project link.
201
+ .option("--column-preset <preset>", "Seed the columns from a named preset instead of the board type's default — see `opvs boards templates`. " +
202
+ "The preset must belong to --board-type (a mismatch is a 422 naming the valid ones). " +
203
+ "e.g. --board-type factory --column-preset factory-full wires every column for claiming — and the board still " +
204
+ "claims NOTHING until it is linked to exactly one active project (`opvs projects add-board <project> <board>`).")
205
+ .option("--mode <mode>", `${MODE_HELP} Default: pipeline.`)
206
+ // `BoardService.create` reads `column_preset` only inside the seed branch.
207
+ .option("--no-seed-columns", "Create the board with NO columns (--column-preset is then ignored). A card needs a column, so nothing can be " +
208
+ "created on it until you add some with `opvs columns create`.")
55
209
  .action(async (opts) => {
56
210
  const wsSlug = program.opts().workspace;
211
+ const mode = oneOf("--mode", opts.mode, BOARD_MODES);
57
212
  try {
58
213
  const client = createClient(wsSlug);
59
214
  const body = { name: opts.name };
@@ -63,9 +218,19 @@ export function registerBoardCommands(program) {
63
218
  body.visibility = opts.visibility;
64
219
  if (opts.boardType)
65
220
  body.board_type = opts.boardType;
221
+ if (opts.columnPreset)
222
+ body.column_preset = opts.columnPreset;
223
+ if (mode)
224
+ body.mode = mode;
225
+ // commander's `--no-x` makes `seedColumns` default true; only the
226
+ // explicit opt-out is sent, so the body without it is unchanged.
227
+ if (opts.seedColumns === false)
228
+ body.seed_default_columns = false;
66
229
  const res = await client.post("/api/v1/board/boards", body);
67
- const data = await res.json();
230
+ const data = (await res.json());
68
231
  printJson(data);
232
+ if (data.board_type === "factory" && data.id)
233
+ await warnIfFactoryUnresolved(client, data.id);
69
234
  }
70
235
  catch (err) {
71
236
  const e = err;
@@ -79,7 +244,12 @@ export function registerBoardCommands(program) {
79
244
  .option("-n, --name <name>", "New name")
80
245
  .option("-d, --description <desc>", "New description")
81
246
  .option("--visibility <vis>", "Visibility: public or private")
82
- .option("--board-type <type>", "Switch board type (tasks↔factory only; other values are rejected)")
247
+ .option("--board-type <type>", `Switch board type (tasks↔factory only; other values are rejected).${BOARD_TYPE_FACTORY_NOTE}`)
248
+ .option("--mode <mode>", MODE_HELP)
249
+ .option("--status <status>", STATUS_HELP)
250
+ .option("--archived <bool>", ARCHIVED_HELP)
251
+ .option("--field-config <json>", FIELD_CONFIG_HELP)
252
+ .option("--field-config-file <path>", "The same as --field-config, read from a file.")
83
253
  .option("--final-stage-column <slug>", "Column slug a card moves to when its final stage completes (board.settings.on_final_stage_move_to_column). Pass '' to clear.")
84
254
  .action(async (id, opts) => {
85
255
  const wsSlug = program.opts().workspace;
@@ -94,6 +264,18 @@ export function registerBoardCommands(program) {
94
264
  body.visibility = opts.visibility;
95
265
  if (opts.boardType)
96
266
  body.board_type = opts.boardType;
267
+ const mode = oneOf("--mode", opts.mode, BOARD_MODES);
268
+ if (mode)
269
+ body.mode = mode;
270
+ const status = oneOf("--status", opts.status, BOARD_STATUSES);
271
+ if (status)
272
+ body.status = status;
273
+ const archived = boolFlag("--archived", opts.archived);
274
+ if (archived !== undefined)
275
+ body.is_archived = archived;
276
+ const fieldConfig = readFieldConfig(opts);
277
+ if (fieldConfig)
278
+ body.field_config = fieldConfig;
97
279
  // The board PATCH replaces `settings` wholesale, so read-modify-write:
98
280
  // fetch current settings, merge just our key, send the full dict back.
99
281
  if (opts.finalStageColumn !== undefined) {
@@ -111,12 +293,15 @@ export function registerBoardCommands(program) {
111
293
  body.settings = settings;
112
294
  }
113
295
  if (Object.keys(body).length === 0) {
114
- console.error("Nothing to update. Provide --name, --description, --visibility, --board-type, or --final-stage-column");
296
+ console.error("Nothing to update. Provide --name, --description, --visibility, --board-type, --mode, --status, " +
297
+ "--archived, --field-config, --field-config-file, or --final-stage-column");
115
298
  process.exit(1);
116
299
  }
117
300
  const res = await client.patch(`/api/v1/board/boards/${id}`, body);
118
301
  const data = await res.json();
119
302
  printJson(data);
303
+ if (opts.boardType === "factory")
304
+ await warnIfFactoryUnresolved(client, id);
120
305
  }
121
306
  catch (err) {
122
307
  const e = err;
@@ -140,6 +325,311 @@ export function registerBoardCommands(program) {
140
325
  process.exit(1);
141
326
  }
142
327
  });
328
+ // ── Templates (ASP-25, plan §B) ───────────────────────────────────
329
+ // Every preset `boards create --column-preset` accepts, WITH its claim
330
+ // wiring. Before ASP-4 the template read dropped the wiring, so a wired
331
+ // preset and an unwired one rendered identically — that is why the roles
332
+ // and move-to slugs are on every column line here, not behind a flag.
333
+ boards
334
+ .command("templates")
335
+ .description("List the column presets a board can be created with, including each column's claim wiring")
336
+ .option("--board-type <type>", "Only presets for this board type, e.g. factory")
337
+ .option("--json", "Print the raw JSON instead of the text listing")
338
+ .action(async (opts) => {
339
+ const wsSlug = program.opts().workspace;
340
+ try {
341
+ const client = createClient(wsSlug);
342
+ const res = await client.get("/api/v1/board/boards/templates");
343
+ const data = (await res.json());
344
+ let templates = data.templates ?? [];
345
+ if (opts.boardType)
346
+ templates = templates.filter((t) => t.board_type === opts.boardType);
347
+ if (opts.json) {
348
+ printJson({ templates });
349
+ return;
350
+ }
351
+ if (templates.length === 0) {
352
+ console.log(opts.boardType ? `No presets for board type '${opts.boardType}'.` : "No presets.");
353
+ return;
354
+ }
355
+ for (const t of templates) {
356
+ const cols = t.columns ?? [];
357
+ const wired = cols.some((c) => (c.claimable_by_roles?.length ?? 0) > 0);
358
+ console.log(`${t.preset} [${t.board_type}] ${cols.length} column(s) ${wired ? "claim-wired" : "NO claim wiring"}` +
359
+ (t.display_name && t.display_name !== t.preset ? ` — ${t.display_name}` : ""));
360
+ if (t.summary)
361
+ console.log(` ${t.summary}`);
362
+ for (const c of cols) {
363
+ const bits = [];
364
+ if (c.mapped_status)
365
+ bits.push(`status=${c.mapped_status}`);
366
+ if (c.is_default)
367
+ bits.push("default");
368
+ if (c.is_done_column)
369
+ bits.push("done");
370
+ if (c.claimable_by_roles?.length)
371
+ bits.push(`roles=${c.claimable_by_roles.join(",")}`);
372
+ if (c.on_claim_move_to)
373
+ bits.push(`on_claim→${c.on_claim_move_to}`);
374
+ if (c.on_done_move_to)
375
+ bits.push(`on_done→${c.on_done_move_to}`);
376
+ console.log(` - ${c.slug}${bits.length ? ` (${bits.join(" · ")})` : ""}`);
377
+ }
378
+ }
379
+ }
380
+ catch (err) {
381
+ const e = err;
382
+ console.error(`Error: ${e.detail || "Failed to list board templates"}`);
383
+ process.exit(1);
384
+ }
385
+ });
386
+ // ── Routing defaults (ASP-25, plan §C / migration 062) ────────────
387
+ withRoutingOptions(boards
388
+ .command("set-routing <board>")
389
+ .description("Set the board's routing defaults (model / provider / harness / claimable-by). Only the flags you pass change."), "board").action(async (board, opts) => {
390
+ const wsSlug = program.opts().workspace;
391
+ try {
392
+ const body = buildRoutingBody(opts);
393
+ if (Object.keys(body).length === 0) {
394
+ console.error("Nothing to set. Provide --model, --provider, --harness, or --claimable-by");
395
+ process.exit(1);
396
+ }
397
+ const client = createClient(wsSlug);
398
+ const res = await client.patch(`/api/v1/board/boards/${board}`, body);
399
+ const data = (await res.json());
400
+ // Report the SERVER's values, not our request: a blank is normalised to
401
+ // null on the way in, and an echo of our own body would hide that.
402
+ printJson({
403
+ id: data.id,
404
+ ref: data.ref,
405
+ default_model: data.default_model ?? null,
406
+ default_provider: data.default_provider ?? null,
407
+ default_harness: data.default_harness ?? null,
408
+ default_claimable_by: data.default_claimable_by ?? null,
409
+ });
410
+ }
411
+ catch (err) {
412
+ const e = err;
413
+ console.error(`Error: ${e.detail || "Failed to set board routing"}`);
414
+ process.exit(1);
415
+ }
416
+ });
417
+ // ── Board ↔ project membership (ASP-25, plan §F) ──────────────────
418
+ // Membership is many-to-many by design, and a board claimed by TWO active
419
+ // projects resolves no bundle at all (`resolution: ambiguous`) — both
420
+ // projects' rules silently stop applying to its cards. `add` can create that
421
+ // state; `move` can silently take a board out of projects the operator is
422
+ // not looking at. Both say what they did.
423
+ boards
424
+ .command("projects <board>")
425
+ .description("List the projects that actively claim this board, and whether that resolves (none | resolved | ambiguous)")
426
+ .action(async (board) => {
427
+ const wsSlug = program.opts().workspace;
428
+ const cfg = getEffectiveConfig(wsSlug);
429
+ try {
430
+ const client = createClient(wsSlug);
431
+ const url = buildUrl(`/api/v1/board/boards/${board}/projects`, undefined, cfg.format);
432
+ const res = await client.get(url);
433
+ await printResponse(res);
434
+ }
435
+ catch (err) {
436
+ const e = err;
437
+ console.error(`Error: ${e.detail || "Failed to list board projects"}`);
438
+ process.exit(1);
439
+ }
440
+ });
441
+ boards
442
+ .command("add-to-project <board> <projectId>")
443
+ .description("Add this board to a project (additive — it stays in any other project). " +
444
+ "If it is already in another active project the board becomes AMBIGUOUS and inherits NO project's rules. " +
445
+ FACTORY_PROJECT_GATE)
446
+ .action(async (board, projectId) => {
447
+ const wsSlug = program.opts().workspace;
448
+ try {
449
+ requireProjectUuid(projectId);
450
+ const client = createClient(wsSlug);
451
+ const res = await client.post(`/api/v1/board/boards/${board}/projects`, {
452
+ project_id: projectId,
453
+ mode: "add",
454
+ });
455
+ const data = (await res.json());
456
+ printJson(data);
457
+ if (data.resolution === "ambiguous") {
458
+ console.error(`Warning: this board is now claimed by ${data.projects?.length ?? "several"} active projects ` +
459
+ "(resolution: ambiguous), so its cards inherit NO project bundle or routing default. " +
460
+ "Use `opvs boards move-to-project` if it should belong to one.");
461
+ }
462
+ await warnIfFactoryUnresolved(client, board);
463
+ }
464
+ catch (err) {
465
+ const e = err;
466
+ console.error(`Error: ${e.detail || "Failed to add board to project"}`);
467
+ process.exit(1);
468
+ }
469
+ });
470
+ boards
471
+ .command("move-to-project <board> <projectId>")
472
+ .description("Move this board into a project: adds it there AND REMOVES it from every other project that claims it, " +
473
+ "in one transaction. Prints the projects it will leave first; requires --yes (or a y at a terminal prompt).")
474
+ .option("--yes", "Confirm the removal from the projects listed. Without it nothing is sent.")
475
+ .action(async (board, projectId, opts) => {
476
+ const wsSlug = program.opts().workspace;
477
+ try {
478
+ requireProjectUuid(projectId);
479
+ const client = createClient(wsSlug);
480
+ // Preview from the same read `opvs boards projects` uses. The POST has
481
+ // no dry-run, so this is the only way to name the casualties BEFORE
482
+ // they happen.
483
+ const before = await client.get(`/api/v1/board/boards/${board}/projects`);
484
+ const current = (await before.json()).projects ?? [];
485
+ const target = projectId.toLowerCase();
486
+ const leaving = current.filter((p) => p.id.toLowerCase() !== target);
487
+ const alreadyIn = current.some((p) => p.id.toLowerCase() === target);
488
+ if (leaving.length === 0) {
489
+ console.error(alreadyIn
490
+ ? `Board ${board} is already only in project ${projectId}. The move removes it from no other project.`
491
+ : `Board ${board} is in no other project. The move removes it from nothing.`);
492
+ }
493
+ else {
494
+ console.error(`Moving board ${board} to project ${projectId} will REMOVE it from ${leaving.length} project(s):`);
495
+ for (const p of leaving)
496
+ console.error(` - ${fmtProject(p)}`);
497
+ console.error("Its cards stop inheriting those projects' commands, skills, rules and routing defaults.");
498
+ }
499
+ const ok = await confirm(`Move board ${board} to project ${projectId}?`, !!opts.yes, leaving.length
500
+ ? `This removes the board from ${leaving.length} other project(s)`
501
+ : "This changes the board's project membership");
502
+ if (!ok) {
503
+ process.exit(1);
504
+ }
505
+ const res = await client.post(`/api/v1/board/boards/${board}/projects`, {
506
+ project_id: projectId,
507
+ mode: "move",
508
+ });
509
+ const data = (await res.json());
510
+ printJson(data);
511
+ // The server's `removed_from` is the truth; the preview was a read that
512
+ // another session could have overtaken. Say so when they differ.
513
+ const removed = new Set((data.removed_from ?? []).map((p) => p.id.toLowerCase()));
514
+ const previewed = new Set(leaving.map((p) => p.id.toLowerCase()));
515
+ const differs = removed.size !== previewed.size || [...removed].some((id) => !previewed.has(id));
516
+ if (differs) {
517
+ console.error("Warning: the projects actually removed differ from the preview above (membership changed in between). " +
518
+ "`removed_from` in the output is what happened.");
519
+ }
520
+ // Moving into an ARCHIVED project leaves the board in no active one.
521
+ await warnIfFactoryUnresolved(client, board);
522
+ }
523
+ catch (err) {
524
+ const e = err;
525
+ console.error(`Error: ${e.detail || "Failed to move board to project"}`);
526
+ process.exit(1);
527
+ }
528
+ });
529
+ // ── Health (ASP-25, plan §J4) ─────────────────────────────────────
530
+ withHealthOptions(boards
531
+ .command("health <board>")
532
+ .description("Structural health of a board and its cards. OPVS reports structure; YOU supply the thresholds. " +
533
+ "A check whose threshold you did not pass prints NOT CHECKED and makes the verdict INCOMPLETE — never a pass.")).action(async (board, opts) => {
534
+ const wsSlug = program.opts().workspace;
535
+ try {
536
+ const client = createClient(wsSlug);
537
+ // JSON always — the text rendering below needs the structure, and a
538
+ // yaml/md workspace format would hand us text we cannot re-render.
539
+ const url = buildUrl(`/api/v1/board/boards/${board}/health`, healthParams(opts));
540
+ const res = await client.get(url);
541
+ const report = (await res.json());
542
+ if (opts.json)
543
+ printJson(report);
544
+ else
545
+ console.log(renderHealthReport(report));
546
+ }
547
+ catch (err) {
548
+ const e = err;
549
+ console.error(`Error: ${e.detail || "Failed to get board health"}`);
550
+ process.exit(1);
551
+ }
552
+ });
553
+ // ── Apply a preset to an existing board (ASP-25, plan §J1) ────────
554
+ boards
555
+ .command("apply-preset <board> <preset>")
556
+ .description("Reshape an EXISTING board's columns to a preset. DRY RUN BY DEFAULT: prints what each column would become " +
557
+ "and how many cards each change puts in scope, and writes nothing. Add --yes to apply. " +
558
+ "Cards are never moved or deleted; columns the preset lacks are left in place.")
559
+ .option("--yes", "Apply the plan printed by the dry run. Without it this command only previews.")
560
+ .option("--manager <slug=type:id>", "Assign a column's owner, e.g. review=user:<ab_user uuid> or deploy=agent:<agent uuid> (repeatable). " +
561
+ "A preset cannot carry an owner, so without this the column is reported unowned — never given one OPVS invented.", (val, prev) => prev.concat([val]), [])
562
+ .option("--json", "Print exactly ONE JSON document on stdout: the dry-run plan, or with --yes the APPLIED plan (the preview is not printed).")
563
+ .action(async (board, preset, opts) => {
564
+ const wsSlug = program.opts().workspace;
565
+ let client;
566
+ try {
567
+ client = createClient(wsSlug);
568
+ }
569
+ catch (err) {
570
+ reportPresetError(err, "Failed to preview preset");
571
+ return;
572
+ }
573
+ const url = `/api/v1/board/boards/${board}/apply-preset`;
574
+ const managers = parseManagers(opts.manager ?? []);
575
+ // Only send `managers` when given, so the body without the flag is
576
+ // byte-identical to what the route documents as its default.
577
+ const request = (mode) => Object.keys(managers).length ? { preset, mode, managers } : { preset, mode };
578
+ let plan;
579
+ try {
580
+ // Always dry-run first, even with --yes: the operator sees the per-column
581
+ // card counts for THIS run before anything is written.
582
+ const res = await client.post(url, request("dry_run"));
583
+ plan = (await res.json());
584
+ }
585
+ catch (err) {
586
+ reportPresetError(err, "Failed to preview preset");
587
+ return;
588
+ }
589
+ if (!opts.yes) {
590
+ if (opts.json)
591
+ printJson(plan);
592
+ else
593
+ console.log(renderPresetPlan(plan));
594
+ console.error("Dry run only — nothing was written. Re-run with --yes to apply this plan.");
595
+ return;
596
+ }
597
+ // ASP-118 F10: with --json, stdout carries ONE document — the applied
598
+ // plan below. Printing the preview too made `| jq` see two objects and a
599
+ // strict JSON.parse of stdout throw.
600
+ if (!opts.json)
601
+ console.log(renderPresetPlan(plan));
602
+ let applied;
603
+ try {
604
+ const res = await client.post(url, request("apply"));
605
+ applied = (await res.json());
606
+ }
607
+ catch (err) {
608
+ reportPresetError(err, "Failed to apply preset");
609
+ return;
610
+ }
611
+ // Text mode used to print only the preview, so a drifted apply was
612
+ // indistinguishable from the plan on screen. What was written is printed
613
+ // too — its header reads APPLIED, the preview's DRY RUN.
614
+ if (opts.json)
615
+ printJson(applied);
616
+ else
617
+ console.log(renderPresetPlan(applied));
618
+ if (!applied.applied) {
619
+ console.error("Error: the server did not apply the preset (applied: false).");
620
+ process.exit(1);
621
+ }
622
+ console.error(`Applied preset ${preset} to board ${board}.`);
623
+ const drift = presetPlanDrift(plan, applied);
624
+ if (drift.length) {
625
+ console.error("Warning: what was APPLIED differs from the dry run taken just before it (the board changed in between). " +
626
+ "The applied plan is what happened:");
627
+ for (const line of drift)
628
+ console.error(` - ${line}`);
629
+ }
630
+ // factory-full on a board in no project is the ORP-6 trap (ASP-113).
631
+ await warnIfFactoryUnresolved(client, board);
632
+ });
143
633
  // ── Export (A.15) ──────────────────────────────────────────────
144
634
  // Read-only board → Markdown snapshot. Output is deterministic — same
145
635
  // board state → same bytes — so it's safe to commit and diff.
@@ -178,4 +668,56 @@ export function registerBoardCommands(program) {
178
668
  }
179
669
  });
180
670
  }
671
+ /**
672
+ * A project id must be a UUID: the body field is `project_id: UUID` and no hop
673
+ * resolves anything else, so a name would be a bare 422. Say what to run instead.
674
+ */
675
+ function requireProjectUuid(projectId) {
676
+ if (!isUuid(projectId)) {
677
+ console.error(`Error: project must be a UUID (got '${projectId}'). Find it with \`opvs projects list\`.`);
678
+ process.exit(1);
679
+ }
680
+ }
681
+ /**
682
+ * A blocking preset conflict is a 409 in BOTH modes, carrying the conflict list
683
+ * in `detail`. Print the list — "409 Conflict" alone tells the operator nothing
684
+ * about which column to fix.
685
+ */
686
+ function reportPresetError(err, fallback) {
687
+ const e = err;
688
+ const detail = e.body?.detail;
689
+ if (e.status === 409 && detail && typeof detail === "object" && Array.isArray(detail.conflicts)) {
690
+ console.error(`Error: ${detail.message ?? "preset cannot be applied"}`);
691
+ for (const k of detail.conflicts) {
692
+ console.error(` ${k.blocking === false ? "NON-BLOCKING" : "BLOCKING"} ${k.slug} ${k.code} — ${k.detail}`);
693
+ }
694
+ }
695
+ else {
696
+ console.error(`Error: ${e.detail || fallback}`);
697
+ }
698
+ process.exit(1);
699
+ }
700
+ /**
701
+ * Parse repeatable `--manager slug=type:id` into the `managers` map
702
+ * `ApplyPresetRequest` takes. The shape is checked here because the server's
703
+ * coherence rule (a `user` needs `managed_by_user_id`, an `agent` needs
704
+ * `managed_by_agent_id`) is exactly what a hand-typed flag gets wrong, and a
705
+ * whole-request 422 names the field path rather than the flag.
706
+ */
707
+ export function parseManagers(raw) {
708
+ const out = {};
709
+ for (const item of raw) {
710
+ const m = /^([a-z0-9_-]+)=(user|agent):(.+)$/.exec(item.trim());
711
+ if (!m || !isUuid(m[3])) {
712
+ console.error(`Error: --manager expects <slug>=<user|agent>:<uuid>, e.g. review=user:<uuid> (got '${item}')`);
713
+ process.exit(1);
714
+ }
715
+ const [, slug, type, id] = m;
716
+ out[slug] =
717
+ type === "user"
718
+ ? { managed_by_type: "user", managed_by_user_id: id }
719
+ : { managed_by_type: "agent", managed_by_agent_id: id };
720
+ }
721
+ return out;
722
+ }
181
723
  //# sourceMappingURL=boards.js.map