@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/README.md CHANGED
@@ -43,12 +43,20 @@ app is registered, start each session by reading its doing tasks, open bugs and
43
43
  goals, and recent decisions:
44
44
 
45
45
  ```bash
46
+ npx odla-ai pm handoff --app <appId>
46
47
  npx odla-ai pm task list --app <appId> --column doing
47
48
  npx odla-ai pm bug list --app <appId> --status open
48
49
  npx odla-ai pm goal list --app <appId> --status open
49
50
  npx odla-ai pm decision list --app <appId> --limit 20
50
51
  ```
51
52
 
53
+ `pm handoff` is the authoritative unresolved-state view: unmet goals, every
54
+ non-done task, and every open or triaged bug. File bugs with actionable context
55
+ (`pm bug add ... --description "reproduction/evidence"`); a title-only bug is
56
+ rejected. When an accepted decision resolves or explicitly retains a bug, link
57
+ it with `pm bug done <id> --decision <decision-id>` or
58
+ `pm bug set <id> --status triaged --decision <decision-id>`.
59
+
52
60
  Read the active task, linked goal, and comments before changing code. Without
53
61
  `--app`, list commands span every project the account co-owns, which is the
54
62
  right first view when coordinating several efforts; scope every write to the
package/dist/bin.cjs CHANGED
@@ -1390,6 +1390,7 @@ function chooseIdMode(options, rows) {
1390
1390
  async function appImport(options) {
1391
1391
  const cfg = await loadProjectConfig(options.configPath);
1392
1392
  const out = options.stdout ?? console;
1393
+ const say = options.json ? (line) => out.error(line) : (line) => out.log(line);
1393
1394
  const doFetch = options.fetch ?? fetch;
1394
1395
  const { tenant } = resolveTenant(cfg, options.env);
1395
1396
  const text = options.file === "-" ? (options.readStdin ?? (() => (0, import_node_fs6.readFileSync)(0, "utf8")))() : (0, import_node_fs6.readFileSync)(options.file, "utf8");
@@ -1411,10 +1412,10 @@ ${detail}${more}`);
1411
1412
  }
1412
1413
  const chunks = (0, import_import.planImportChunks)(ops);
1413
1414
  const result = { tenant, rows: ops.length, chunks: chunks.length, committed: 0, duplicate: 0, txIds: [], ignored };
1414
- if (ignored.length > 0) out.log(`ignoring server-maintained field(s): ${ignored.join(", ")} \u2014 odla-db stamps these on write`);
1415
+ if (ignored.length > 0) say(`ignoring server-maintained field(s): ${ignored.join(", ")} \u2014 odla-db stamps these on write`);
1415
1416
  if (options.dryRun || options.yes !== true) {
1416
1417
  const why = options.dryRun ? "--dry-run" : "no --yes";
1417
- out.log(`${tenant}: would upsert ${ops.length} row(s) across ${chunks.length} transaction(s) \u2014 nothing written (${why})`);
1418
+ say(`${tenant}: would upsert ${ops.length} row(s) across ${chunks.length} transaction(s) \u2014 nothing written (${why})`);
1418
1419
  if (options.json) out.log(JSON.stringify(result, null, 2));
1419
1420
  return result;
1420
1421
  }
@@ -1623,6 +1624,7 @@ function printPlan(out, verb, pre, opts) {
1623
1624
  async function appTransfer(options) {
1624
1625
  const cfg = await loadProjectConfig(options.configPath);
1625
1626
  const out = options.stdout ?? console;
1627
+ const say = options.json ? (line) => out.error(line) : (line) => out.log(line);
1626
1628
  const doFetch = options.fetch ?? fetch;
1627
1629
  const tenants = bothTenants(cfg);
1628
1630
  const route2 = endpointsFor(options.verb, tenants);
@@ -1643,7 +1645,7 @@ async function appTransfer(options) {
1643
1645
  throw new Error(`pre-flight failed${pre.body.error?.code ? ` (${pre.body.error.code})` : ""}: ${pre.body.error?.message ?? pre.status}`);
1644
1646
  }
1645
1647
  const plan = pre.body;
1646
- printPlan(out, options.verb, plan, options);
1648
+ printPlan({ log: say }, options.verb, plan, options);
1647
1649
  if (options.verb === "go-live" && !plan.targetEmpty) {
1648
1650
  throw new Error(
1649
1651
  `${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.`
@@ -1651,7 +1653,7 @@ async function appTransfer(options) {
1651
1653
  }
1652
1654
  if (plan.blockers.length > 0) throw new Error(`cannot ${options.verb}: ${plan.blockers.map((b) => b.message).join("; ")}`);
1653
1655
  if (options.dryRun || options.yes !== true) {
1654
- out.log(`nothing written (${options.dryRun ? "--dry-run" : "no --yes"})`);
1656
+ say(`nothing written (${options.dryRun ? "--dry-run" : "no --yes"})`);
1655
1657
  if (options.json) out.log(JSON.stringify(plan, null, 2));
1656
1658
  return { ok: true, plan };
1657
1659
  }
@@ -1665,7 +1667,7 @@ async function appTransfer(options) {
1665
1667
  );
1666
1668
  if (res2.status !== 200) throw new Error(`promote failed${res2.body.error?.code ? ` (${res2.body.error.code})` : ""}: ${res2.body.error?.message ?? res2.status}`);
1667
1669
  if (options.json) out.log(JSON.stringify(res2.body, null, 2));
1668
- else out.log(`${route2.target}: promoted ${(res2.body.applied ?? []).join(", ")}`);
1670
+ else say(`${route2.target}: promoted ${(res2.body.applied ?? []).join(", ")}`);
1669
1671
  return { ok: true, plan, result: res2.body };
1670
1672
  }
1671
1673
  const res = await api(
@@ -1684,7 +1686,7 @@ async function appTransfer(options) {
1684
1686
  doFetch
1685
1687
  );
1686
1688
  if (res.status !== 200) throw new Error(`${options.verb} failed${res.body.error?.code ? ` (${res.body.error.code})` : ""}: ${res.body.error?.message ?? res.status}`);
1687
- out.log(`${route2.target}: replaced from ${route2.source} (tx ${res.body.destination?.maxTx}, epoch ${res.body.destination?.epoch}) \u2014 connected clients resync automatically`);
1689
+ say(`${route2.target}: replaced from ${route2.source} (tx ${res.body.destination?.maxTx}, epoch ${res.body.destination?.epoch}) \u2014 connected clients resync automatically`);
1688
1690
  if (options.includeFiles) await copyFiles(cfg, token, route2, doFetch, out);
1689
1691
  if (options.json) out.log(JSON.stringify(res.body, null, 2));
1690
1692
  return { ok: true, plan, result: res.body };
@@ -6424,13 +6426,14 @@ Usage:
6424
6426
  odla-ai app owners add <email> [--email <odla-account>] [--json]
6425
6427
  odla-ai app owners remove <email> [--email <odla-account>] [--json]
6426
6428
  odla-ai pm <goal|task|decision|bug> list [--app <id>] [--status <s>] [--severity <s>] [--column <c>] [--q <text>] [--json]
6427
- odla-ai pm <goal|task|decision|bug> add --app <id> --title <t> [--severity high|--column doing|--goal <id>|--body <text>|--proof <text>]
6429
+ odla-ai pm <goal|task|decision|bug> add --app <id> --title <t> [--severity high|--column doing|--goal <id>|--description <text>|--body <text>|--proof <text>]
6428
6430
  odla-ai pm <goal|task|decision|bug> get <id> [--json]
6429
- odla-ai pm <goal|task|decision|bug> set <id> [--status <s>|--column <c>|--assignee <id>|--no-assignee|--goal <id>]
6430
- odla-ai pm <goal|task|decision|bug> done <id>
6431
+ odla-ai pm <goal|task|decision|bug> set <id> [--status <s>|--column <c>|--assignee <id>|--no-assignee|--goal <id>|--decision <id>]
6432
+ odla-ai pm <goal|task|decision|bug> done <id> [--decision <accepted-decision-id>]
6431
6433
  odla-ai pm <goal|task|decision|bug> comment <id> --body "..."
6432
6434
  odla-ai pm <goal|task|decision|bug> comments <id> [--json]
6433
6435
  odla-ai pm <goal|task|decision|bug> rm <id>
6436
+ odla-ai pm handoff --app <id> [--json]
6434
6437
  odla-ai whoami [--json]
6435
6438
  odla-ai runbook ask "<question>" [--app <id>] [--all] [--json]
6436
6439
  odla-ai runbook search "<question>" [--app <id>] [--all] [--limit <n>] [--json]
@@ -7189,6 +7192,7 @@ var FIELD_MAP = {
7189
7192
  column: { key: "column" },
7190
7193
  rank: { key: "rank", num: true },
7191
7194
  goal: { key: "goalId" },
7195
+ decision: { key: "decisionId" },
7192
7196
  assignee: { key: "assigneeId" },
7193
7197
  due: { key: "dueAt", num: true },
7194
7198
  proof: { key: "proof" },
@@ -7227,6 +7231,14 @@ function collectFields(parsed, allowClear) {
7227
7231
  }
7228
7232
  return out;
7229
7233
  }
7234
+ function collectEntityFields(entity, parsed, allowClear) {
7235
+ const fields = collectFields(parsed, allowClear);
7236
+ if ((entity === "task" || entity === "bug") && fields.body !== void 0) {
7237
+ if (fields.description === void 0) fields.description = fields.body;
7238
+ delete fields.body;
7239
+ }
7240
+ return fields;
7241
+ }
7230
7242
  function statusCol(entity, r) {
7231
7243
  if (entity === "bug") return `${r.status ?? ""}/${r.severity ?? ""}`;
7232
7244
  if (entity === "task") return String(r.column ?? "");
@@ -7241,7 +7253,14 @@ function emit(ctx, value, human) {
7241
7253
  }
7242
7254
  async function pmList(ctx, entity, parsed) {
7243
7255
  const q = new URLSearchParams();
7244
- const filters = { status: "status", severity: "severity", column: "column", goal: "goalId", assignee: "assigneeId" };
7256
+ const filters = {
7257
+ status: "status",
7258
+ severity: "severity",
7259
+ column: "column",
7260
+ goal: "goalId",
7261
+ assignee: "assigneeId",
7262
+ decision: "decisionId"
7263
+ };
7245
7264
  const app = stringOpt(parsed.options.app);
7246
7265
  if (app) q.set("app", app);
7247
7266
  for (const [flag, param] of Object.entries(filters)) {
@@ -7263,8 +7282,10 @@ async function pmList(ctx, entity, parsed) {
7263
7282
  async function pmAdd(ctx, entity, parsed) {
7264
7283
  const appId = stringOpt(parsed.options.app);
7265
7284
  if (!appId) throw new Error("pm add needs --app <appId>");
7266
- const input = collectFields(parsed, false);
7285
+ const input = collectEntityFields(entity, parsed, false);
7267
7286
  if (!input.title) throw new Error("pm add needs --title <title>");
7287
+ if (entity === "bug" && !input.description)
7288
+ throw new Error("pm bug add needs --description <context> (or --body <context>)");
7268
7289
  const res = await pmRequest(ctx, "POST", `/${entity}`, { appId, input });
7269
7290
  emit(ctx, res, () => ctx.out.log(`created ${entity} ${res.id}`));
7270
7291
  }
@@ -7273,16 +7294,71 @@ async function pmGet(ctx, entity, id) {
7273
7294
  emit(ctx, record5, () => printRecord(ctx, entity, record5));
7274
7295
  }
7275
7296
  async function pmSet(ctx, entity, id, parsed) {
7276
- const patch2 = collectFields(parsed, true);
7297
+ const patch2 = collectEntityFields(entity, parsed, true);
7277
7298
  if (Object.keys(patch2).length === 0)
7278
7299
  throw new Error("pm set needs at least one field flag (e.g. --status doing, --assignee me, --no-assignee)");
7279
7300
  const res = await pmRequest(ctx, "PATCH", `/${entity}/${encodeURIComponent(id)}`, { patch: patch2 });
7280
7301
  emit(ctx, res, () => ctx.out.log(res.record ? `${res.record.id} [${statusCol(entity, res.record)}] ${res.record.appId} ${res.record.title ?? ""}` : `updated ${entity} ${id}`));
7281
7302
  }
7282
- async function pmDone(ctx, entity, id) {
7283
- const res = await pmRequest(ctx, "PATCH", `/${entity}/${encodeURIComponent(id)}`, { patch: DONE[entity] });
7303
+ async function pmDone(ctx, entity, id, parsed) {
7304
+ const decisionId = stringOpt(parsed.options.decision);
7305
+ if (decisionId && entity !== "bug") throw new Error("--decision is only valid when completing a bug");
7306
+ const patch2 = { ...DONE[entity], ...decisionId ? { decisionId } : {} };
7307
+ const res = await pmRequest(ctx, "PATCH", `/${entity}/${encodeURIComponent(id)}`, { patch: patch2 });
7284
7308
  emit(ctx, res, () => ctx.out.log(`${entity} ${id} \u2192 done`));
7285
7309
  }
7310
+ async function allRecords(ctx, entity, appId) {
7311
+ const records = [];
7312
+ for (; ; ) {
7313
+ const q = new URLSearchParams({
7314
+ app: appId,
7315
+ limit: "100",
7316
+ offset: String(records.length)
7317
+ });
7318
+ const page = await pmRequest(ctx, "GET", `/${entity}?${q}`);
7319
+ records.push(...page.records);
7320
+ if (records.length >= page.total || page.records.length === 0) return records;
7321
+ }
7322
+ }
7323
+ async function pmHandoff(ctx, parsed) {
7324
+ const appId = stringOpt(parsed.options.app);
7325
+ if (!appId) throw new Error("pm handoff needs --app <appId>");
7326
+ const [goals, tasks, bugs] = await Promise.all([
7327
+ allRecords(ctx, "goal", appId),
7328
+ allRecords(ctx, "task", appId),
7329
+ allRecords(ctx, "bug", appId)
7330
+ ]);
7331
+ const handoff = {
7332
+ appId,
7333
+ unmetGoals: goals.filter((record5) => record5.status !== "met"),
7334
+ activeTasks: tasks.filter((record5) => record5.column !== "done"),
7335
+ openBugs: bugs.filter((record5) => record5.status !== "fixed" && record5.status !== "wontfix")
7336
+ };
7337
+ const result = {
7338
+ ...handoff,
7339
+ clean: !handoff.unmetGoals.length && !handoff.activeTasks.length && !handoff.openBugs.length
7340
+ };
7341
+ emit(ctx, result, () => {
7342
+ if (result.clean) {
7343
+ ctx.out.log(`${appId}: no unresolved PM work`);
7344
+ return;
7345
+ }
7346
+ ctx.out.log(`${appId}: authoritative PM handoff`);
7347
+ for (const [label, records] of [
7348
+ ["unmet goals", result.unmetGoals],
7349
+ ["active tasks", result.activeTasks],
7350
+ ["open bugs", result.openBugs]
7351
+ ]) {
7352
+ ctx.out.log(`${label}:`);
7353
+ if (!records.length) ctx.out.log("- (none)");
7354
+ else for (const record5 of records) printRecord(
7355
+ ctx,
7356
+ label === "unmet goals" ? "goal" : label === "active tasks" ? "task" : "bug",
7357
+ record5
7358
+ );
7359
+ }
7360
+ });
7361
+ }
7286
7362
  async function pmComment(ctx, entity, id, parsed) {
7287
7363
  const body = stringOpt(parsed.options.body);
7288
7364
  if (!body) throw new Error('pm comment needs --body "..."');
@@ -7333,6 +7409,7 @@ var ALLOWED = [
7333
7409
  "target",
7334
7410
  "description",
7335
7411
  "desc",
7412
+ "decision",
7336
7413
  "limit",
7337
7414
  "offset",
7338
7415
  "q"
@@ -7355,6 +7432,10 @@ async function buildContext(parsed, deps) {
7355
7432
  }
7356
7433
  async function pmCommand(parsed, deps = {}) {
7357
7434
  const word = parsed.positionals[1] ?? "";
7435
+ if (word === "handoff") {
7436
+ assertArgs(parsed, ALLOWED, 2);
7437
+ return pmHandoff(await buildContext(parsed, deps), parsed);
7438
+ }
7358
7439
  const entity = ALIASES[word];
7359
7440
  if (!entity) throw new Error(`unknown pm entity "${word}". Try "odla-ai pm bug list" (goal|task|decision|bug).`);
7360
7441
  const action = parsed.positionals[2] ?? "list";
@@ -7375,7 +7456,7 @@ async function pmCommand(parsed, deps = {}) {
7375
7456
  case "move":
7376
7457
  return pmSet(ctx, entity, requireId(id, action), parsed);
7377
7458
  case "done":
7378
- return pmDone(ctx, entity, requireId(id, action));
7459
+ return pmDone(ctx, entity, requireId(id, action), parsed);
7379
7460
  case "comment":
7380
7461
  return pmComment(ctx, entity, requireId(id, action), parsed);
7381
7462
  case "comments":
@@ -7851,7 +7932,10 @@ var COMMAND_SURFACE = {
7851
7932
  doctor: {},
7852
7933
  help: {},
7853
7934
  init: {},
7854
- pm: PM_ENTITIES,
7935
+ pm: {
7936
+ ...PM_ENTITIES,
7937
+ handoff: {}
7938
+ },
7855
7939
  provision: {},
7856
7940
  runbook: {
7857
7941
  ask: {},