@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.
- package/README.md +60 -0
- package/dist/.build-stamp.json +4 -4
- package/dist/commands/boards.d.ts +57 -0
- package/dist/commands/boards.js +546 -4
- package/dist/commands/boards.js.map +1 -1
- package/dist/commands/columns.d.ts +51 -0
- package/dist/commands/columns.js +274 -16
- package/dist/commands/columns.js.map +1 -1
- package/dist/commands/comments.js +48 -14
- package/dist/commands/comments.js.map +1 -1
- package/dist/commands/files.js +34 -0
- package/dist/commands/files.js.map +1 -1
- package/dist/commands/markers.js +53 -1
- package/dist/commands/markers.js.map +1 -1
- package/dist/commands/members.d.ts +33 -0
- package/dist/commands/members.js +96 -11
- package/dist/commands/members.js.map +1 -1
- package/dist/commands/projects.d.ts +8 -0
- package/dist/commands/projects.js +138 -6
- package/dist/commands/projects.js.map +1 -1
- package/dist/commands/stages.js +14 -3
- package/dist/commands/stages.js.map +1 -1
- package/dist/commands/tasks.js +288 -18
- package/dist/commands/tasks.js.map +1 -1
- package/dist/lib/factory-gate.d.ts +38 -0
- package/dist/lib/factory-gate.js +65 -0
- package/dist/lib/factory-gate.js.map +1 -0
- package/dist/lib/health.d.ts +54 -0
- package/dist/lib/health.js +126 -0
- package/dist/lib/health.js.map +1 -0
- package/dist/lib/options.d.ts +17 -0
- package/dist/lib/options.js +37 -0
- package/dist/lib/options.js.map +1 -1
- package/dist/lib/routing.d.ts +41 -0
- package/dist/lib/routing.js +60 -0
- package/dist/lib/routing.js.map +1 -0
- package/package.json +2 -2
package/dist/commands/boards.js
CHANGED
|
@@ -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>",
|
|
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>",
|
|
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,
|
|
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
|