@odla-ai/cli 0.22.1 → 0.24.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/dist/index.cjs CHANGED
@@ -1450,6 +1450,7 @@ function chooseIdMode(options, rows) {
1450
1450
  async function appImport(options) {
1451
1451
  const cfg = await loadProjectConfig(options.configPath);
1452
1452
  const out = options.stdout ?? console;
1453
+ const say = options.json ? (line) => out.error(line) : (line) => out.log(line);
1453
1454
  const doFetch = options.fetch ?? fetch;
1454
1455
  const { tenant } = resolveTenant(cfg, options.env);
1455
1456
  const text = options.file === "-" ? (options.readStdin ?? (() => (0, import_node_fs6.readFileSync)(0, "utf8")))() : (0, import_node_fs6.readFileSync)(options.file, "utf8");
@@ -1471,10 +1472,10 @@ ${detail}${more}`);
1471
1472
  }
1472
1473
  const chunks = (0, import_import.planImportChunks)(ops);
1473
1474
  const result = { tenant, rows: ops.length, chunks: chunks.length, committed: 0, duplicate: 0, txIds: [], ignored };
1474
- if (ignored.length > 0) out.log(`ignoring server-maintained field(s): ${ignored.join(", ")} \u2014 odla-db stamps these on write`);
1475
+ if (ignored.length > 0) say(`ignoring server-maintained field(s): ${ignored.join(", ")} \u2014 odla-db stamps these on write`);
1475
1476
  if (options.dryRun || options.yes !== true) {
1476
1477
  const why = options.dryRun ? "--dry-run" : "no --yes";
1477
- out.log(`${tenant}: would upsert ${ops.length} row(s) across ${chunks.length} transaction(s) \u2014 nothing written (${why})`);
1478
+ say(`${tenant}: would upsert ${ops.length} row(s) across ${chunks.length} transaction(s) \u2014 nothing written (${why})`);
1478
1479
  if (options.json) out.log(JSON.stringify(result, null, 2));
1479
1480
  return result;
1480
1481
  }
@@ -1683,6 +1684,7 @@ function printPlan(out, verb, pre, opts) {
1683
1684
  async function appTransfer(options) {
1684
1685
  const cfg = await loadProjectConfig(options.configPath);
1685
1686
  const out = options.stdout ?? console;
1687
+ const say = options.json ? (line) => out.error(line) : (line) => out.log(line);
1686
1688
  const doFetch = options.fetch ?? fetch;
1687
1689
  const tenants = bothTenants(cfg);
1688
1690
  const route2 = endpointsFor(options.verb, tenants);
@@ -1703,7 +1705,7 @@ async function appTransfer(options) {
1703
1705
  throw new Error(`pre-flight failed${pre.body.error?.code ? ` (${pre.body.error.code})` : ""}: ${pre.body.error?.message ?? pre.status}`);
1704
1706
  }
1705
1707
  const plan = pre.body;
1706
- printPlan(out, options.verb, plan, options);
1708
+ printPlan({ log: say }, options.verb, plan, options);
1707
1709
  if (options.verb === "go-live" && !plan.targetEmpty) {
1708
1710
  throw new Error(
1709
1711
  `${route2.target} already has data \u2014 go-live has already happened. Use \`odla-ai app promote --yes\` to push schema and rules, or \`odla-ai app refresh-sandbox --yes\` to pull live back down.`
@@ -1711,7 +1713,7 @@ async function appTransfer(options) {
1711
1713
  }
1712
1714
  if (plan.blockers.length > 0) throw new Error(`cannot ${options.verb}: ${plan.blockers.map((b) => b.message).join("; ")}`);
1713
1715
  if (options.dryRun || options.yes !== true) {
1714
- out.log(`nothing written (${options.dryRun ? "--dry-run" : "no --yes"})`);
1716
+ say(`nothing written (${options.dryRun ? "--dry-run" : "no --yes"})`);
1715
1717
  if (options.json) out.log(JSON.stringify(plan, null, 2));
1716
1718
  return { ok: true, plan };
1717
1719
  }
@@ -1725,7 +1727,7 @@ async function appTransfer(options) {
1725
1727
  );
1726
1728
  if (res2.status !== 200) throw new Error(`promote failed${res2.body.error?.code ? ` (${res2.body.error.code})` : ""}: ${res2.body.error?.message ?? res2.status}`);
1727
1729
  if (options.json) out.log(JSON.stringify(res2.body, null, 2));
1728
- else out.log(`${route2.target}: promoted ${(res2.body.applied ?? []).join(", ")}`);
1730
+ else say(`${route2.target}: promoted ${(res2.body.applied ?? []).join(", ")}`);
1729
1731
  return { ok: true, plan, result: res2.body };
1730
1732
  }
1731
1733
  const res = await api(
@@ -1744,7 +1746,7 @@ async function appTransfer(options) {
1744
1746
  doFetch
1745
1747
  );
1746
1748
  if (res.status !== 200) throw new Error(`${options.verb} failed${res.body.error?.code ? ` (${res.body.error.code})` : ""}: ${res.body.error?.message ?? res.status}`);
1747
- out.log(`${route2.target}: replaced from ${route2.source} (tx ${res.body.destination?.maxTx}, epoch ${res.body.destination?.epoch}) \u2014 connected clients resync automatically`);
1749
+ say(`${route2.target}: replaced from ${route2.source} (tx ${res.body.destination?.maxTx}, epoch ${res.body.destination?.epoch}) \u2014 connected clients resync automatically`);
1748
1750
  if (options.includeFiles) await copyFiles(cfg, token, route2, doFetch, out);
1749
1751
  if (options.json) out.log(JSON.stringify(res.body, null, 2));
1750
1752
  return { ok: true, plan, result: res.body };
@@ -6484,13 +6486,14 @@ Usage:
6484
6486
  odla-ai app owners add <email> [--email <odla-account>] [--json]
6485
6487
  odla-ai app owners remove <email> [--email <odla-account>] [--json]
6486
6488
  odla-ai pm <goal|task|decision|bug> list [--app <id>] [--status <s>] [--severity <s>] [--column <c>] [--q <text>] [--json]
6487
- odla-ai pm <goal|task|decision|bug> add --app <id> --title <t> [--severity high|--column doing|--goal <id>|--body <text>|--proof <text>]
6489
+ odla-ai pm <goal|task|decision|bug> add --app <id> --title <t> [--severity high|--column doing|--goal <id>|--description <text>|--body <text>|--proof <text>]
6488
6490
  odla-ai pm <goal|task|decision|bug> get <id> [--json]
6489
- odla-ai pm <goal|task|decision|bug> set <id> [--status <s>|--column <c>|--assignee <id>|--no-assignee|--goal <id>]
6490
- odla-ai pm <goal|task|decision|bug> done <id>
6491
+ odla-ai pm <goal|task|decision|bug> set <id> [--status <s>|--column <c>|--assignee <id>|--no-assignee|--goal <id>|--decision <id>]
6492
+ odla-ai pm <goal|task|decision|bug> done <id> [--decision <accepted-decision-id>]
6491
6493
  odla-ai pm <goal|task|decision|bug> comment <id> --body "..."
6492
6494
  odla-ai pm <goal|task|decision|bug> comments <id> [--json]
6493
6495
  odla-ai pm <goal|task|decision|bug> rm <id>
6496
+ odla-ai pm handoff --app <id> [--json]
6494
6497
  odla-ai whoami [--json]
6495
6498
  odla-ai runbook ask "<question>" [--app <id>] [--all] [--json]
6496
6499
  odla-ai runbook search "<question>" [--app <id>] [--all] [--limit <n>] [--json]
@@ -7249,6 +7252,7 @@ var FIELD_MAP = {
7249
7252
  column: { key: "column" },
7250
7253
  rank: { key: "rank", num: true },
7251
7254
  goal: { key: "goalId" },
7255
+ decision: { key: "decisionId" },
7252
7256
  assignee: { key: "assigneeId" },
7253
7257
  due: { key: "dueAt", num: true },
7254
7258
  proof: { key: "proof" },
@@ -7287,6 +7291,14 @@ function collectFields(parsed, allowClear) {
7287
7291
  }
7288
7292
  return out;
7289
7293
  }
7294
+ function collectEntityFields(entity, parsed, allowClear) {
7295
+ const fields = collectFields(parsed, allowClear);
7296
+ if ((entity === "task" || entity === "bug") && fields.body !== void 0) {
7297
+ if (fields.description === void 0) fields.description = fields.body;
7298
+ delete fields.body;
7299
+ }
7300
+ return fields;
7301
+ }
7290
7302
  function statusCol(entity, r) {
7291
7303
  if (entity === "bug") return `${r.status ?? ""}/${r.severity ?? ""}`;
7292
7304
  if (entity === "task") return String(r.column ?? "");
@@ -7301,7 +7313,14 @@ function emit(ctx, value, human) {
7301
7313
  }
7302
7314
  async function pmList(ctx, entity, parsed) {
7303
7315
  const q = new URLSearchParams();
7304
- const filters = { status: "status", severity: "severity", column: "column", goal: "goalId", assignee: "assigneeId" };
7316
+ const filters = {
7317
+ status: "status",
7318
+ severity: "severity",
7319
+ column: "column",
7320
+ goal: "goalId",
7321
+ assignee: "assigneeId",
7322
+ decision: "decisionId"
7323
+ };
7305
7324
  const app = stringOpt(parsed.options.app);
7306
7325
  if (app) q.set("app", app);
7307
7326
  for (const [flag, param] of Object.entries(filters)) {
@@ -7323,8 +7342,10 @@ async function pmList(ctx, entity, parsed) {
7323
7342
  async function pmAdd(ctx, entity, parsed) {
7324
7343
  const appId = stringOpt(parsed.options.app);
7325
7344
  if (!appId) throw new Error("pm add needs --app <appId>");
7326
- const input = collectFields(parsed, false);
7345
+ const input = collectEntityFields(entity, parsed, false);
7327
7346
  if (!input.title) throw new Error("pm add needs --title <title>");
7347
+ if (entity === "bug" && !input.description)
7348
+ throw new Error("pm bug add needs --description <context> (or --body <context>)");
7328
7349
  const res = await pmRequest(ctx, "POST", `/${entity}`, { appId, input });
7329
7350
  emit(ctx, res, () => ctx.out.log(`created ${entity} ${res.id}`));
7330
7351
  }
@@ -7333,16 +7354,71 @@ async function pmGet(ctx, entity, id) {
7333
7354
  emit(ctx, record5, () => printRecord(ctx, entity, record5));
7334
7355
  }
7335
7356
  async function pmSet(ctx, entity, id, parsed) {
7336
- const patch2 = collectFields(parsed, true);
7357
+ const patch2 = collectEntityFields(entity, parsed, true);
7337
7358
  if (Object.keys(patch2).length === 0)
7338
7359
  throw new Error("pm set needs at least one field flag (e.g. --status doing, --assignee me, --no-assignee)");
7339
7360
  const res = await pmRequest(ctx, "PATCH", `/${entity}/${encodeURIComponent(id)}`, { patch: patch2 });
7340
7361
  emit(ctx, res, () => ctx.out.log(res.record ? `${res.record.id} [${statusCol(entity, res.record)}] ${res.record.appId} ${res.record.title ?? ""}` : `updated ${entity} ${id}`));
7341
7362
  }
7342
- async function pmDone(ctx, entity, id) {
7343
- const res = await pmRequest(ctx, "PATCH", `/${entity}/${encodeURIComponent(id)}`, { patch: DONE[entity] });
7363
+ async function pmDone(ctx, entity, id, parsed) {
7364
+ const decisionId = stringOpt(parsed.options.decision);
7365
+ if (decisionId && entity !== "bug") throw new Error("--decision is only valid when completing a bug");
7366
+ const patch2 = { ...DONE[entity], ...decisionId ? { decisionId } : {} };
7367
+ const res = await pmRequest(ctx, "PATCH", `/${entity}/${encodeURIComponent(id)}`, { patch: patch2 });
7344
7368
  emit(ctx, res, () => ctx.out.log(`${entity} ${id} \u2192 done`));
7345
7369
  }
7370
+ async function allRecords(ctx, entity, appId) {
7371
+ const records = [];
7372
+ for (; ; ) {
7373
+ const q = new URLSearchParams({
7374
+ app: appId,
7375
+ limit: "100",
7376
+ offset: String(records.length)
7377
+ });
7378
+ const page = await pmRequest(ctx, "GET", `/${entity}?${q}`);
7379
+ records.push(...page.records);
7380
+ if (records.length >= page.total || page.records.length === 0) return records;
7381
+ }
7382
+ }
7383
+ async function pmHandoff(ctx, parsed) {
7384
+ const appId = stringOpt(parsed.options.app);
7385
+ if (!appId) throw new Error("pm handoff needs --app <appId>");
7386
+ const [goals, tasks, bugs] = await Promise.all([
7387
+ allRecords(ctx, "goal", appId),
7388
+ allRecords(ctx, "task", appId),
7389
+ allRecords(ctx, "bug", appId)
7390
+ ]);
7391
+ const handoff = {
7392
+ appId,
7393
+ unmetGoals: goals.filter((record5) => record5.status !== "met"),
7394
+ activeTasks: tasks.filter((record5) => record5.column !== "done"),
7395
+ openBugs: bugs.filter((record5) => record5.status !== "fixed" && record5.status !== "wontfix")
7396
+ };
7397
+ const result = {
7398
+ ...handoff,
7399
+ clean: !handoff.unmetGoals.length && !handoff.activeTasks.length && !handoff.openBugs.length
7400
+ };
7401
+ emit(ctx, result, () => {
7402
+ if (result.clean) {
7403
+ ctx.out.log(`${appId}: no unresolved PM work`);
7404
+ return;
7405
+ }
7406
+ ctx.out.log(`${appId}: authoritative PM handoff`);
7407
+ for (const [label, records] of [
7408
+ ["unmet goals", result.unmetGoals],
7409
+ ["active tasks", result.activeTasks],
7410
+ ["open bugs", result.openBugs]
7411
+ ]) {
7412
+ ctx.out.log(`${label}:`);
7413
+ if (!records.length) ctx.out.log("- (none)");
7414
+ else for (const record5 of records) printRecord(
7415
+ ctx,
7416
+ label === "unmet goals" ? "goal" : label === "active tasks" ? "task" : "bug",
7417
+ record5
7418
+ );
7419
+ }
7420
+ });
7421
+ }
7346
7422
  async function pmComment(ctx, entity, id, parsed) {
7347
7423
  const body = stringOpt(parsed.options.body);
7348
7424
  if (!body) throw new Error('pm comment needs --body "..."');
@@ -7393,6 +7469,7 @@ var ALLOWED = [
7393
7469
  "target",
7394
7470
  "description",
7395
7471
  "desc",
7472
+ "decision",
7396
7473
  "limit",
7397
7474
  "offset",
7398
7475
  "q"
@@ -7415,6 +7492,10 @@ async function buildContext(parsed, deps) {
7415
7492
  }
7416
7493
  async function pmCommand(parsed, deps = {}) {
7417
7494
  const word = parsed.positionals[1] ?? "";
7495
+ if (word === "handoff") {
7496
+ assertArgs(parsed, ALLOWED, 2);
7497
+ return pmHandoff(await buildContext(parsed, deps), parsed);
7498
+ }
7418
7499
  const entity = ALIASES[word];
7419
7500
  if (!entity) throw new Error(`unknown pm entity "${word}". Try "odla-ai pm bug list" (goal|task|decision|bug).`);
7420
7501
  const action = parsed.positionals[2] ?? "list";
@@ -7435,7 +7516,7 @@ async function pmCommand(parsed, deps = {}) {
7435
7516
  case "move":
7436
7517
  return pmSet(ctx, entity, requireId(id, action), parsed);
7437
7518
  case "done":
7438
- return pmDone(ctx, entity, requireId(id, action));
7519
+ return pmDone(ctx, entity, requireId(id, action), parsed);
7439
7520
  case "comment":
7440
7521
  return pmComment(ctx, entity, requireId(id, action), parsed);
7441
7522
  case "comments":
@@ -7911,7 +7992,10 @@ var COMMAND_SURFACE = {
7911
7992
  doctor: {},
7912
7993
  help: {},
7913
7994
  init: {},
7914
- pm: PM_ENTITIES,
7995
+ pm: {
7996
+ ...PM_ENTITIES,
7997
+ handoff: {}
7998
+ },
7915
7999
  provision: {},
7916
8000
  runbook: {
7917
8001
  ask: {},