@odla-ai/cli 0.42.1 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -6275,7 +6275,7 @@ async function stageWorkspacePair(baselineSource, workspaceSource, options = {})
6275
6275
  }
6276
6276
  }
6277
6277
 
6278
- // ../harness/dist/chunk-QZXCQSPZ.js
6278
+ // ../harness/dist/chunk-GYWQM76X.js
6279
6279
  var import_crypto = require("crypto");
6280
6280
  var import_promises5 = require("fs/promises");
6281
6281
  var import_path5 = require("path");
@@ -6612,7 +6612,7 @@ function validateSnapshot(snapshot, limits) {
6612
6612
  }
6613
6613
  }
6614
6614
 
6615
- // ../harness/dist/chunk-QZXCQSPZ.js
6615
+ // ../harness/dist/chunk-GYWQM76X.js
6616
6616
  var import_child_process4 = require("child_process");
6617
6617
  var import_promises6 = require("fs/promises");
6618
6618
  var import_path6 = require("path");
@@ -6628,6 +6628,7 @@ var import_os3 = require("os");
6628
6628
  var import_path8 = require("path");
6629
6629
  var import_ai4 = require("@odla-ai/ai");
6630
6630
  var import_ai5 = require("@odla-ai/ai");
6631
+ var import_child_process6 = require("child_process");
6631
6632
  var import_promises9 = require("fs/promises");
6632
6633
  var import_path9 = require("path");
6633
6634
 
@@ -6915,7 +6916,7 @@ function looksLikeDestination(value2) {
6915
6916
  return /^(?:[a-z][a-z0-9+.-]*:\/\/|\/|\\\\)/i.test(text3) || /^[\w.-]+\.[a-z]{2,}(?:[/:]|$)/i.test(text3);
6916
6917
  }
6917
6918
 
6918
- // ../harness/dist/chunk-QZXCQSPZ.js
6919
+ // ../harness/dist/chunk-GYWQM76X.js
6919
6920
  var import_promises10 = require("fs/promises");
6920
6921
  var import_promises11 = require("fs/promises");
6921
6922
  var import_path10 = require("path");
@@ -7183,7 +7184,7 @@ async function buildCodeGraph(input) {
7183
7184
  return builder.build();
7184
7185
  }
7185
7186
 
7186
- // ../harness/dist/chunk-QZXCQSPZ.js
7187
+ // ../harness/dist/chunk-GYWQM76X.js
7187
7188
  var import_crypto4 = require("crypto");
7188
7189
  async function digestStagedWorkspace(root, limits) {
7189
7190
  const files = [];
@@ -8247,7 +8248,10 @@ Never claim a build or test passed unless odla_run_recipe returned that result.`
8247
8248
  var V2_SYSTEM_PROMPT = `You are the coding agent inside an odla Code harness.
8248
8249
  Start by orienting: odla_list shows the files in the workspace and odla_search
8249
8250
  finds a literal string across them. Prefer those over guessing a path.
8250
- Then odla_read a bounded range, and odla_apply_git_diff to mutate.
8251
+ Then odla_read a bounded range, and odla_apply_git_diff to mutate. When you
8252
+ need several independent searches or file ranges, issue those read-only calls
8253
+ together in one turn; their results stay ordered and the harness overlaps them.
8254
+ Never issue odla_apply_git_diff or odla_run_recipe alongside another tool call.
8251
8255
  For mutations, call odla_apply_git_diff with raw git diff text. It must start
8252
8256
  with "diff --git a/<path> b/<path>", include matching "---" and "+++" file
8253
8257
  headers and numbered "@@" hunks, and never use "*** Begin Patch" wrappers.
@@ -8278,18 +8282,26 @@ var SYSTEM_PROMPT_FOR = {
8278
8282
  };
8279
8283
  function codeSkill(opts) {
8280
8284
  let seq = 0;
8285
+ let nextCompletion = 1;
8286
+ const completed = /* @__PURE__ */ new Map();
8281
8287
  const call4 = async (tool, input, signal) => {
8288
+ const sequence = ++seq;
8282
8289
  const startedAt = Date.now();
8283
8290
  const response2 = await opts.broker.execute(
8284
8291
  { lease: opts.lease, workspaceDir: opts.workspaceDir, signal },
8285
- { requestId: `bench-${tool}-${++seq}`, tool, input }
8292
+ { requestId: `bench-${tool}-${sequence}`, tool, input }
8286
8293
  );
8287
- opts.onToolCall?.({
8294
+ completed.set(sequence, {
8288
8295
  tool,
8289
8296
  ok: response2.ok,
8290
8297
  durationMs: Date.now() - startedAt,
8291
8298
  ...response2.ok ? {} : { error: String(response2.content).slice(0, 300) }
8292
8299
  });
8300
+ while (completed.has(nextCompletion)) {
8301
+ const completion = completed.get(nextCompletion);
8302
+ completed.delete(nextCompletion++);
8303
+ opts.onToolCall?.(completion);
8304
+ }
8293
8305
  return { content: response2.content, isError: !response2.ok };
8294
8306
  };
8295
8307
  const read22 = {
@@ -8305,6 +8317,7 @@ function codeSkill(opts) {
8305
8317
  },
8306
8318
  additionalProperties: false
8307
8319
  },
8320
+ concurrency: "parallel",
8308
8321
  handler: (input, ctx) => call4("sandbox.read", input, ctx.signal)
8309
8322
  };
8310
8323
  const applyPatch = {
@@ -8346,6 +8359,7 @@ function codeSkill(opts) {
8346
8359
  },
8347
8360
  additionalProperties: false
8348
8361
  },
8362
+ concurrency: "parallel",
8349
8363
  handler: (input, ctx) => call4("sandbox.list", input, ctx.signal)
8350
8364
  };
8351
8365
  const searchFiles = {
@@ -8362,11 +8376,13 @@ function codeSkill(opts) {
8362
8376
  },
8363
8377
  additionalProperties: false
8364
8378
  },
8379
+ concurrency: "parallel",
8365
8380
  handler: (input, ctx) => call4("sandbox.search", input, ctx.signal)
8366
8381
  };
8367
8382
  const graphTool = (name, tool, description, required) => ({
8368
8383
  name,
8369
8384
  description,
8385
+ concurrency: "parallel",
8370
8386
  inputSchema: {
8371
8387
  type: "object",
8372
8388
  ...required ? { required: ["query"] } : {},
@@ -8544,6 +8560,24 @@ function createCodeRuntimeInference(options) {
8544
8560
  var DEFAULT_MAX_FILES = 2e4;
8545
8561
  var DEFAULT_MAX_RESULTS = 100;
8546
8562
  var DEFAULT_MAX_FILE_BYTES = 512 * 1024;
8563
+ function createWorkspaceFileRegistry(limit = DEFAULT_MAX_FILES, enumerate = registeredFiles) {
8564
+ const cache2 = /* @__PURE__ */ new Map();
8565
+ return {
8566
+ files(root) {
8567
+ const existing = cache2.get(root);
8568
+ if (existing) return existing;
8569
+ const pending = enumerate(root, limit).then((paths) => Object.freeze(paths));
8570
+ cache2.set(root, pending);
8571
+ void pending.catch(() => {
8572
+ if (cache2.get(root) === pending) cache2.delete(root);
8573
+ });
8574
+ return pending;
8575
+ },
8576
+ invalidate(root) {
8577
+ cache2.delete(root);
8578
+ }
8579
+ };
8580
+ }
8547
8581
  async function registeredFiles(root, limit = DEFAULT_MAX_FILES) {
8548
8582
  const paths = [];
8549
8583
  const walk = async (directory) => {
@@ -8574,28 +8608,122 @@ function listWorkspace(paths, options = {}) {
8574
8608
  return scoped.slice(0, max);
8575
8609
  }
8576
8610
  async function searchWorkspace(root, paths, options) {
8577
- const query = options.caseSensitive === false ? options.query.toLowerCase() : options.query;
8578
- if (!query) throw new TypeError("search query must be a non-empty string");
8611
+ options.signal?.throwIfAborted();
8612
+ if (!options.query) throw new TypeError("search query must be a non-empty string");
8579
8613
  const maxResults = options.maxResults ?? DEFAULT_MAX_RESULTS;
8580
8614
  const maxFileBytes = options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES;
8581
8615
  const scoped = listWorkspace(paths, { ...options.prefix ? { prefix: options.prefix } : {}, maxEntries: paths.length });
8616
+ if (scoped.length === 0) return [];
8617
+ try {
8618
+ return await nativeSearch(root, scoped, { ...options, maxResults, maxFileBytes });
8619
+ } catch (error) {
8620
+ options.signal?.throwIfAborted();
8621
+ return fallbackSearch(root, scoped, { ...options, maxResults, maxFileBytes });
8622
+ }
8623
+ }
8624
+ var MAX_NATIVE_ARG_BYTES = 96 * 1024;
8625
+ async function nativeSearch(root, paths, options) {
8626
+ const batches = [];
8627
+ let batch = [];
8628
+ let bytes = 0;
8629
+ for (const path of paths) {
8630
+ const size = Buffer.byteLength(path) + 1;
8631
+ if (batch.length > 0 && bytes + size > MAX_NATIVE_ARG_BYTES) {
8632
+ batches.push(batch);
8633
+ batch = [];
8634
+ bytes = 0;
8635
+ }
8636
+ batch.push(path);
8637
+ bytes += size;
8638
+ }
8639
+ if (batch.length > 0) batches.push(batch);
8640
+ const matches = [];
8641
+ for (const files of batches) {
8642
+ const remaining = options.maxResults - matches.length;
8643
+ if (remaining <= 0) break;
8644
+ matches.push(...await nativeSearchBatch(root, files, options, remaining));
8645
+ }
8646
+ return matches;
8647
+ }
8648
+ function nativeSearchBatch(root, paths, options, remaining) {
8649
+ return new Promise((resolveMatches, reject) => {
8650
+ const args = [
8651
+ "--fixed-strings",
8652
+ "--json",
8653
+ "--no-messages",
8654
+ "--sort=path",
8655
+ `--max-filesize=${options.maxFileBytes}`,
8656
+ "--max-columns=4096",
8657
+ "--max-columns-preview",
8658
+ options.caseSensitive === false ? "--ignore-case" : "--case-sensitive",
8659
+ "--",
8660
+ options.query,
8661
+ ...paths
8662
+ ];
8663
+ const child = (0, import_child_process6.spawn)("rg", args, {
8664
+ cwd: root,
8665
+ stdio: ["ignore", "pipe", "ignore"],
8666
+ ...options.signal ? { signal: options.signal } : {}
8667
+ });
8668
+ const matches = [];
8669
+ let carry = "";
8670
+ let stopped = false;
8671
+ const consume = (line2) => {
8672
+ if (matches.length >= remaining) return;
8673
+ let event;
8674
+ try {
8675
+ event = JSON.parse(line2);
8676
+ } catch {
8677
+ return;
8678
+ }
8679
+ const path = event.data?.path?.text;
8680
+ const lineNumber = event.data?.line_number;
8681
+ const source = event.data?.lines?.text;
8682
+ if (event.type !== "match" || path === void 0 || lineNumber === void 0 || source === void 0) return;
8683
+ matches.push({ path, line: lineNumber, text: source.trim().slice(0, 240) });
8684
+ if (matches.length >= remaining) {
8685
+ stopped = true;
8686
+ child.kill();
8687
+ }
8688
+ };
8689
+ child.stdout.setEncoding("utf8");
8690
+ child.stdout.on("data", (chunk) => {
8691
+ carry += chunk;
8692
+ let newline = carry.indexOf("\n");
8693
+ while (newline >= 0) {
8694
+ consume(carry.slice(0, newline));
8695
+ carry = carry.slice(newline + 1);
8696
+ newline = carry.indexOf("\n");
8697
+ }
8698
+ });
8699
+ child.once("error", reject);
8700
+ child.once("close", (code) => {
8701
+ if (carry) consume(carry);
8702
+ if (stopped || code === 0 || code === 1) resolveMatches(matches);
8703
+ else reject(new Error(`native search exited with status ${code ?? "unknown"}`));
8704
+ });
8705
+ });
8706
+ }
8707
+ async function fallbackSearch(root, scoped, options) {
8708
+ const query = options.caseSensitive === false ? options.query.toLowerCase() : options.query;
8582
8709
  const matches = [];
8583
8710
  for (const path of scoped) {
8584
- if (matches.length >= maxResults) break;
8711
+ options.signal?.throwIfAborted();
8712
+ if (matches.length >= options.maxResults) break;
8585
8713
  let source;
8586
8714
  try {
8587
8715
  source = await (0, import_promises9.readFile)((0, import_path9.resolve)(root, path));
8588
8716
  } catch {
8589
8717
  continue;
8590
8718
  }
8591
- if (source.byteLength > maxFileBytes || source.includes(0)) continue;
8719
+ if (source.byteLength > options.maxFileBytes || source.includes(0)) continue;
8592
8720
  const lines = source.toString("utf8").split("\n");
8593
8721
  for (let index = 0; index < lines.length; index += 1) {
8594
8722
  const raw = lines[index];
8595
8723
  const haystack = options.caseSensitive === false ? raw.toLowerCase() : raw;
8596
8724
  if (!haystack.includes(query)) continue;
8597
8725
  matches.push({ path, line: index + 1, text: raw.trim().slice(0, 240) });
8598
- if (matches.length >= maxResults) break;
8726
+ if (matches.length >= options.maxResults) break;
8599
8727
  }
8600
8728
  }
8601
8729
  return matches;
@@ -8850,6 +8978,9 @@ function workspaceGraphs(workspaceDir, paths) {
8850
8978
  cache.set(workspaceDir, built);
8851
8979
  return built;
8852
8980
  }
8981
+ function forgetWorkspaceGraphs(workspaceDir) {
8982
+ cache.delete(workspaceDir);
8983
+ }
8853
8984
  var shortId = (id2) => id2.slice(id2.indexOf(":") + 1);
8854
8985
  function renderOverview(graphs, prefix) {
8855
8986
  const rows = rollup(graphs.graph, FILE, prefix === void 0 ? {} : { prefix });
@@ -8894,7 +9025,7 @@ var GRAPH_TOOLS = /* @__PURE__ */ new Set([
8894
9025
  "sandbox.who_imports",
8895
9026
  "sandbox.who_touches"
8896
9027
  ]);
8897
- async function read(context, request3, options, policy) {
9028
+ async function read(context, request3, options, policy, registry) {
8898
9029
  exactKeys(request3.input, ["path", "startLine", "endLine"]);
8899
9030
  const path = stringField(request3.input, "path");
8900
9031
  const startLine = optionalInteger(request3.input.startLine) ?? 1;
@@ -8902,7 +9033,7 @@ async function read(context, request3, options, policy) {
8902
9033
  if (endLine < startLine || endLine - startLine + 1 > (options.maxReadLines ?? 2e3)) {
8903
9034
  throw new TypeError("requested line range exceeds its bound");
8904
9035
  }
8905
- const paths = await registeredFiles(context.workspaceDir, 2e4);
9036
+ const paths = await registry.files(context.workspaceDir);
8906
9037
  if (!paths.includes(path)) {
8907
9038
  throw new TypeError(`no such file in the staged workspace: "${path}". Use sandbox.overview, sandbox.where_is or sandbox.search to find the correct path.`);
8908
9039
  }
@@ -8922,13 +9053,13 @@ async function read(context, request3, options, policy) {
8922
9053
  }
8923
9054
  return response(request3, true, content2, { path, startLine, endLine: Math.min(endLine, lines.length) });
8924
9055
  }
8925
- async function list(context, request3, options, policy) {
9056
+ async function list(context, request3, options, policy, registry) {
8926
9057
  exactKeys(request3.input, ["prefix", "maxEntries"]);
8927
9058
  const raw = request3.input.prefix;
8928
9059
  const prefix = typeof raw === "string" && raw.length > 0 ? raw : void 0;
8929
9060
  const maxEntries = optionalInteger(request3.input.maxEntries) ?? 1e3;
8930
9061
  if (maxEntries > 5e3) throw new TypeError("maxEntries exceeds its bound");
8931
- const paths = await registeredFiles(context.workspaceDir, 2e4);
9062
+ const paths = await registry.files(context.workspaceDir);
8932
9063
  const allowed = await policy.list(policyContext(context, request3, options, { paths, ...prefix ? { prefix } : {} }));
8933
9064
  if (!allowed) return response(request3, false, "tool denied by CaMeL policy");
8934
9065
  const entries = listWorkspace(paths, { ...prefix ? { prefix } : {}, maxEntries });
@@ -8946,7 +9077,7 @@ async function list(context, request3, options, policy) {
8946
9077
  { count: entries.length, truncated }
8947
9078
  );
8948
9079
  }
8949
- async function search(context, request3, options, policy) {
9080
+ async function search(context, request3, options, policy, registry) {
8950
9081
  exactKeys(request3.input, ["query", "prefix", "maxResults", "caseSensitive"]);
8951
9082
  const query = stringField(request3.input, "query");
8952
9083
  if (query.length > 512) throw new TypeError("search query exceeds its bound");
@@ -8955,21 +9086,22 @@ async function search(context, request3, options, policy) {
8955
9086
  const maxResults = optionalInteger(request3.input.maxResults) ?? 100;
8956
9087
  if (maxResults > 500) throw new TypeError("maxResults exceeds its bound");
8957
9088
  const caseSensitive = request3.input.caseSensitive === void 0 ? true : request3.input.caseSensitive === true;
8958
- const paths = await registeredFiles(context.workspaceDir, 2e4);
9089
+ const paths = await registry.files(context.workspaceDir);
8959
9090
  const allowed = await policy.search(policyContext(context, request3, options, { paths, query, ...prefix ? { prefix } : {} }));
8960
9091
  if (!allowed) return response(request3, false, "tool denied by CaMeL policy");
8961
9092
  const matches = await searchWorkspace(context.workspaceDir, paths, {
8962
9093
  query,
8963
9094
  maxResults,
8964
9095
  caseSensitive,
8965
- ...prefix ? { prefix } : {}
9096
+ ...prefix ? { prefix } : {},
9097
+ ...context.signal ? { signal: context.signal } : {}
8966
9098
  });
8967
9099
  if (!matches.length) return response(request3, true, `No match for "${query}".`, { count: 0 });
8968
9100
  return response(request3, true, matches.map((match) => `${match.path}:${match.line}: ${match.text}`).join("\n"), {
8969
9101
  count: matches.length
8970
9102
  });
8971
9103
  }
8972
- async function graphQuery(context, request3, options, policy) {
9104
+ async function graphQuery(context, request3, options, policy, registry) {
8973
9105
  exactKeys(request3.input, ["query"]);
8974
9106
  const raw = request3.input.query;
8975
9107
  const query = typeof raw === "string" ? raw : "";
@@ -8979,7 +9111,7 @@ async function graphQuery(context, request3, options, policy) {
8979
9111
  selector: query
8980
9112
  }));
8981
9113
  if (!allowed) return response(request3, false, "tool denied by CaMeL policy");
8982
- const paths = await registeredFiles(context.workspaceDir, 2e4);
9114
+ const paths = await registry.files(context.workspaceDir);
8983
9115
  const graphs = await workspaceGraphs(context.workspaceDir, paths);
8984
9116
  if (request3.tool === "sandbox.overview") {
8985
9117
  return response(request3, true, renderOverview(graphs, query || void 0));
@@ -8993,23 +9125,38 @@ function createCodeToolBroker(options) {
8993
9125
  validateOptions(options);
8994
9126
  const recipes = new Map(options.recipes.map((recipe2) => [recipe2.id, recipe2]));
8995
9127
  const policy = createCodePolicyGate(options);
8996
- let tail = Promise.resolve();
9128
+ const registry = createWorkspaceFileRegistry();
9129
+ let barrier = Promise.resolve();
9130
+ const activeReads = /* @__PURE__ */ new Set();
8997
9131
  return {
8998
9132
  execute(context, request3) {
8999
- const result = tail.then(() => route2(context, request3, options, recipes, policy));
9000
- tail = result.then(() => void 0, () => void 0);
9133
+ if (isReadTool(request3.tool)) {
9134
+ const result2 = barrier.then(() => route2(context, request3, options, recipes, policy, registry));
9135
+ const settled = result2.then(() => void 0, () => void 0);
9136
+ activeReads.add(settled);
9137
+ void settled.then(() => {
9138
+ activeReads.delete(settled);
9139
+ });
9140
+ return result2;
9141
+ }
9142
+ const earlierReads = [...activeReads];
9143
+ const result = barrier.then(() => Promise.all(earlierReads)).then(() => route2(context, request3, options, recipes, policy, registry));
9144
+ barrier = result.then(() => void 0, () => void 0);
9001
9145
  return result;
9002
9146
  }
9003
9147
  };
9004
9148
  }
9005
- async function route2(context, request3, options, recipes, policy) {
9149
+ function isReadTool(tool) {
9150
+ return tool === "sandbox.read" || tool === "sandbox.list" || tool === "sandbox.search" || GRAPH_TOOLS.has(tool);
9151
+ }
9152
+ async function route2(context, request3, options, recipes, policy, registry) {
9006
9153
  try {
9007
9154
  if (context.signal?.aborted) throw new TypeError("tool request was cancelled");
9008
- if (request3.tool === "sandbox.read") return await read(context, request3, options, policy);
9009
- if (request3.tool === "sandbox.list") return await list(context, request3, options, policy);
9010
- if (request3.tool === "sandbox.search") return await search(context, request3, options, policy);
9011
- if (GRAPH_TOOLS.has(request3.tool)) return await graphQuery(context, request3, options, policy);
9012
- if (request3.tool === "sandbox.apply_patch") return await patch(context, request3, options, policy);
9155
+ if (request3.tool === "sandbox.read") return await read(context, request3, options, policy, registry);
9156
+ if (request3.tool === "sandbox.list") return await list(context, request3, options, policy, registry);
9157
+ if (request3.tool === "sandbox.search") return await search(context, request3, options, policy, registry);
9158
+ if (GRAPH_TOOLS.has(request3.tool)) return await graphQuery(context, request3, options, policy, registry);
9159
+ if (request3.tool === "sandbox.apply_patch") return await patch(context, request3, options, policy, registry);
9013
9160
  return await recipe(context, request3, options, recipes, policy);
9014
9161
  } catch (reason) {
9015
9162
  return response(request3, false, toolFailureMessage(reason));
@@ -9024,7 +9171,7 @@ function toolFailureMessage(reason) {
9024
9171
  if (code === "EACCES" || code === "EPERM") return "that path is not readable through this tool";
9025
9172
  return "tool failed closed";
9026
9173
  }
9027
- async function patch(context, request3, options, policy) {
9174
+ async function patch(context, request3, options, policy, registry) {
9028
9175
  exactKeys(request3.input, ["patch"]);
9029
9176
  const value2 = stringField(request3.input, "patch");
9030
9177
  const paths = validateCodePatch(value2, options.maxPatchBytes ?? 256 * 1024);
@@ -9034,6 +9181,8 @@ async function patch(context, request3, options, policy) {
9034
9181
  const allowed = await policy.patch(policyContext(context, request3, options, { patch: value2 }));
9035
9182
  if (!allowed) return response(request3, false, "tool denied by CaMeL policy");
9036
9183
  await applyCodePatch(context.workspaceDir, value2, paths);
9184
+ registry.invalidate(context.workspaceDir);
9185
+ forgetWorkspaceGraphs(context.workspaceDir);
9037
9186
  return response(request3, true, `Applied patch to ${paths.length} file(s).`, { paths });
9038
9187
  }
9039
9188
  async function recipe(context, request3, options, recipes, policy) {
@@ -10675,7 +10824,8 @@ Usage:
10675
10824
  odla-ai pm bug list [--app <id>] [--status <s>] [--severity <s>] [--goal <id>] [--assignee <id>] [--decision <id>] [--q <text>] [--limit <n>] [--offset <n>] [--json]
10676
10825
  odla-ai pm goal add --app <id> --title <t> [--status <s>] [--proof <text>] [--target <pct>] [--mutation-id <id>] [--json]
10677
10826
  odla-ai pm task add --app <id> --title <t> [--column <backlog|ready|doing|review|done>] [--goal <id>|--alignment-decision <id>] [--assignee <id>] [--description <text>|--body <text>] [--acceptance <text>] [--execution <human|agent|either>] [--due <epoch-ms>] [--mutation-id <id>] [--json]
10678
- odla-ai pm next --app <id> [--project <id>] [--json]
10827
+ odla-ai pm next --app <id> [--project <id>] [--verbose] [--json]
10828
+ odla-ai pm start --app <id> [<task-id>] [--goal <id>] [--verbose] [--mutation-id <id>] [--json] [claims the top Ready task in one call]
10679
10829
  odla-ai pm watch --app <id> [--cursor <cursor>] [--entity goal|task|decision|bug] [--action created|updated|deleted|comment.created|comment.updated] [--state <state>] [--by <principalId>] [--self <principalId>] [--interval <s>] [--timeout <s>] [--json|--jsonl]
10680
10830
  odla-ai pm task ready <id> --expected-revision <n> [--goal <id>|--alignment-decision <id>] [--description <text>|--body <text>] [--acceptance <text>] [--execution <human|agent|either>] [--mutation-id <id>] [--json]
10681
10831
  odla-ai pm task claim <id> --expected-revision <n> [--mutation-id <id>] [--json]
@@ -10696,7 +10846,7 @@ Usage:
10696
10846
  odla-ai pm <goal|task|decision|bug> comments <id> [--json]
10697
10847
  odla-ai pm <goal|task|decision|bug> history <id> [--limit <n>] [--json]
10698
10848
  odla-ai pm <goal|task|decision|bug> rm <id>
10699
- odla-ai pm handoff --app <id> [--project <id>] [--json]
10849
+ odla-ai pm handoff --app <id> [--project <id>] [--verbose] [--json]
10700
10850
  odla-ai discuss groups [--json]
10701
10851
  odla-ai discuss list [--app <id>] [--q <text>] [--state open|resolved|all] [--mentions] [--json]
10702
10852
  odla-ai discuss read <topic> [--limit <n> --offset <n>] [--json]
@@ -10861,6 +11011,16 @@ Commands:
10861
11011
  execution contract and a reviewed revision. Any caller with PM
10862
11012
  mutation access to that app/project may do it; claims coordinate
10863
11013
  execution but never own or lock the PM record.
11014
+ To pick up work: "pm next" reads open goals, Ready candidates,
11015
+ and work in progress; "pm start" claims the top Ready task in
11016
+ one call, compare-and-swapped on the revision it just read, and
11017
+ returns the task, its goal, and the acceptance criteria to work
11018
+ against. Finish with "pm task done <id>", or hand it back with
11019
+ "pm task release <id> --expected-revision <n>". "pm start" never
11020
+ marks work Ready: that contract is reviewed separately so an
11021
+ agent cannot authorize its own work.
11022
+ Intake output is projected to the fields an executing caller
11023
+ acts on; --verbose returns whole records with their audit trail.
10864
11024
  bug Intent-first alias for PM bugs. "bug report" writes to
10865
11025
  odla PM; odla product defects do not belong in GitHub Issues.
10866
11026
  discuss Group discussions (via @odla-ai/chat) for the apps you co-own:
@@ -11611,6 +11771,15 @@ var FIELD_MAP = {
11611
11771
  execution: { key: "executionMode" },
11612
11772
  "expected-revision": { key: "expectedRevision", num: true }
11613
11773
  };
11774
+ var PmRequestError = class extends Error {
11775
+ constructor(status, message2) {
11776
+ super(message2);
11777
+ this.status = status;
11778
+ this.name = "PmRequestError";
11779
+ }
11780
+ status;
11781
+ };
11782
+ var isRevisionConflict = (error) => error instanceof PmRequestError && error.status === 409;
11614
11783
  async function pmRequest(ctx, method, path, body) {
11615
11784
  const response2 = await ctx.doFetch(`${ctx.platformUrl}/registry/pm${path}`, {
11616
11785
  method,
@@ -11630,7 +11799,8 @@ async function pmRequest(ctx, method, path, body) {
11630
11799
  const message2 = error.message;
11631
11800
  if (typeof message2 === "string" && message2.length > 0) detail = message2;
11632
11801
  }
11633
- throw new Error(
11802
+ throw new PmRequestError(
11803
+ response2.status,
11634
11804
  `pm ${method} ${path} failed: ${detail ?? `registry returned ${response2.status}`} (${response2.status})`
11635
11805
  );
11636
11806
  }
@@ -11819,103 +11989,196 @@ async function pmTaskLifecycle(ctx, id2, action2, parsed) {
11819
11989
  ctx.out.log(`task: ${label} \u2192 ${state2}`);
11820
11990
  });
11821
11991
  }
11822
- async function allRecords(ctx, entity, appId, projectId) {
11992
+ async function pmRemove(ctx, entity, id2) {
11993
+ await pmRequest(ctx, "DELETE", `/${entity}/${encodeURIComponent(id2)}`);
11994
+ ctx.out.log(`deleted ${entity} ${id2}`);
11995
+ }
11996
+
11997
+ // src/pm-lean.ts
11998
+ var COMMON = ["id", "appId", "projectId", "title", "revision"];
11999
+ var PER_ENTITY = {
12000
+ goal: ["status", "proof", "targetPct", "currentPct"],
12001
+ task: [
12002
+ "column",
12003
+ "goalId",
12004
+ "alignmentDecisionId",
12005
+ "description",
12006
+ "acceptanceCriteria",
12007
+ "executionMode",
12008
+ "assigneeId",
12009
+ "claimedByPrincipalId",
12010
+ "dueAt"
12011
+ ],
12012
+ decision: ["status", "body", "supersedesId"],
12013
+ bug: ["status", "severity", "description", "goalId", "assigneeId", "decisionId"]
12014
+ };
12015
+ function leanRecord(entity, record11) {
12016
+ const out = {};
12017
+ for (const key of [...COMMON, ...PER_ENTITY[entity]]) {
12018
+ const value2 = record11[key];
12019
+ if (value2 !== void 0 && value2 !== null) out[key] = value2;
12020
+ }
12021
+ return out;
12022
+ }
12023
+ function leanRecords(entity, records, verbose) {
12024
+ return verbose ? records : records.map((record11) => leanRecord(entity, record11));
12025
+ }
12026
+
12027
+ // src/pm-intake.ts
12028
+ var READY_COLUMN = "todo";
12029
+ var UNMET_GOAL_STATUS = "open";
12030
+ var ACTIVE_TASK_COLUMNS = "backlog,todo,doing,review";
12031
+ var OPEN_BUG_STATUSES = "open,triaged";
12032
+ var wantsVerbose = (parsed) => parsed.options.verbose === true;
12033
+ async function listFiltered(ctx, entity, appId, projectId, filters) {
11823
12034
  const records = [];
11824
12035
  for (; ; ) {
11825
- const q = new URLSearchParams({
11826
- app: appId,
11827
- limit: "100",
11828
- offset: String(records.length)
11829
- });
12036
+ const q = new URLSearchParams({ app: appId, limit: "100", offset: String(records.length) });
11830
12037
  if (projectId) q.set("project", projectId);
12038
+ for (const [key, value2] of Object.entries(filters)) q.set(key, value2);
11831
12039
  const page2 = await pmRequest(ctx, "GET", `/${entity}?${q}`);
11832
12040
  records.push(...page2.records);
11833
12041
  if (records.length >= page2.total || page2.records.length === 0) return records;
11834
12042
  }
11835
12043
  }
11836
- async function pmNext(ctx, parsed) {
12044
+ function requireApp(ctx, parsed, command) {
11837
12045
  const appId = stringOpt(parsed.options.app) ?? ctx.appId;
11838
- const projectId = stringOpt(parsed.options.project) ?? ctx.projectId;
11839
- if (!appId) throw new Error("pm next needs --app <appId>");
12046
+ if (!appId) throw new Error(`pm ${command} needs --app <appId>`);
12047
+ return { appId, projectId: stringOpt(parsed.options.project) ?? ctx.projectId };
12048
+ }
12049
+ async function pmNext(ctx, parsed) {
12050
+ const { appId, projectId } = requireApp(ctx, parsed, "next");
12051
+ const verbose = wantsVerbose(parsed);
11840
12052
  const [goals, tasks] = await Promise.all([
11841
- allRecords(ctx, "goal", appId, projectId),
11842
- allRecords(ctx, "task", appId, projectId)
12053
+ listFiltered(ctx, "goal", appId, projectId, { status: UNMET_GOAL_STATUS }),
12054
+ // One request for both live columns; the split below is over open work only.
12055
+ listFiltered(ctx, "task", appId, projectId, { column: `${READY_COLUMN},doing` })
11843
12056
  ]);
12057
+ const ready = tasks.filter((record11) => record11.column === READY_COLUMN);
12058
+ const doing = tasks.filter((record11) => record11.column === "doing");
12059
+ const guidance = !goals.length ? "discuss alignment with the user before creating or claiming project work" : !ready.length ? 'refine a linked Backlog task and mark it Ready with "pm task ready <id>"' : `claim the top Ready task in one call: odla-ai pm start --app ${appId}`;
11844
12060
  const result = {
11845
12061
  appId,
11846
12062
  projectId,
11847
- openGoals: goals.filter((record11) => record11.status === "open"),
11848
- doing: tasks.filter((record11) => record11.column === "doing"),
11849
- ready: tasks.filter((record11) => record11.column === "todo")
12063
+ openGoals: leanRecords("goal", goals, verbose),
12064
+ doing: leanRecords("task", doing, verbose),
12065
+ ready: leanRecords("task", ready, verbose),
12066
+ next: guidance
11850
12067
  };
11851
12068
  emit2(ctx, result, () => {
11852
12069
  ctx.out.log(`${appId}: goal-aligned work intake (read only)`);
11853
- for (const [label, records] of [
11854
- ["doing", result.doing],
11855
- ["ready", result.ready],
11856
- ["open goals", result.openGoals]
12070
+ for (const [label, entity, records] of [
12071
+ ["doing", "task", doing],
12072
+ ["ready", "task", ready],
12073
+ ["open goals", "goal", goals]
11857
12074
  ]) {
11858
12075
  ctx.out.log(`${label}:`);
11859
12076
  if (!records.length) ctx.out.log("- (none)");
11860
- else for (const record11 of records) printRecord(
11861
- ctx,
11862
- label === "open goals" ? "goal" : "task",
11863
- record11
11864
- );
11865
- }
11866
- if (!result.openGoals.length) {
11867
- ctx.out.log("next: discuss alignment with the user before creating or claiming project work");
11868
- } else if (!result.ready.length) {
11869
- ctx.out.log("next: refine a linked Backlog task and mark it Ready");
11870
- } else {
11871
- ctx.out.log("next: review a Ready task, then claim it with its revision");
12077
+ else for (const record11 of records) printRecord(ctx, entity, record11);
11872
12078
  }
12079
+ ctx.out.log(`next: ${guidance}`);
11873
12080
  });
11874
12081
  }
11875
12082
  async function pmHandoff(ctx, parsed) {
11876
- const appId = stringOpt(parsed.options.app) ?? ctx.appId;
11877
- const projectId = stringOpt(parsed.options.project) ?? ctx.projectId;
11878
- if (!appId) throw new Error("pm handoff needs --app <appId>");
12083
+ const { appId, projectId } = requireApp(ctx, parsed, "handoff");
12084
+ const verbose = wantsVerbose(parsed);
11879
12085
  const [goals, tasks, bugs] = await Promise.all([
11880
- allRecords(ctx, "goal", appId, projectId),
11881
- allRecords(ctx, "task", appId, projectId),
11882
- allRecords(ctx, "bug", appId, projectId)
12086
+ listFiltered(ctx, "goal", appId, projectId, { status: UNMET_GOAL_STATUS }),
12087
+ listFiltered(ctx, "task", appId, projectId, { column: ACTIVE_TASK_COLUMNS }),
12088
+ listFiltered(ctx, "bug", appId, projectId, { status: OPEN_BUG_STATUSES })
11883
12089
  ]);
11884
- const handoff = {
12090
+ const clean4 = !goals.length && !tasks.length && !bugs.length;
12091
+ const result = {
11885
12092
  appId,
11886
12093
  projectId,
11887
- unmetGoals: goals.filter((record11) => record11.status !== "met"),
11888
- activeTasks: tasks.filter((record11) => record11.column !== "done"),
11889
- openBugs: bugs.filter((record11) => record11.status !== "fixed" && record11.status !== "wontfix")
11890
- };
11891
- const result = {
11892
- ...handoff,
11893
- clean: !handoff.unmetGoals.length && !handoff.activeTasks.length && !handoff.openBugs.length
12094
+ unmetGoals: leanRecords("goal", goals, verbose),
12095
+ activeTasks: leanRecords("task", tasks, verbose),
12096
+ openBugs: leanRecords("bug", bugs, verbose),
12097
+ clean: clean4
11894
12098
  };
11895
12099
  emit2(ctx, result, () => {
11896
- if (result.clean) {
12100
+ if (clean4) {
11897
12101
  ctx.out.log(`${appId}: no unresolved PM work`);
11898
12102
  return;
11899
12103
  }
11900
12104
  ctx.out.log(`${appId}: authoritative PM handoff`);
11901
- for (const [label, records] of [
11902
- ["unmet goals", result.unmetGoals],
11903
- ["active tasks", result.activeTasks],
11904
- ["open bugs", result.openBugs]
12105
+ for (const [label, entity, records] of [
12106
+ ["unmet goals", "goal", goals],
12107
+ ["active tasks", "task", tasks],
12108
+ ["open bugs", "bug", bugs]
11905
12109
  ]) {
11906
12110
  ctx.out.log(`${label}:`);
11907
12111
  if (!records.length) ctx.out.log("- (none)");
11908
- else for (const record11 of records) printRecord(
11909
- ctx,
11910
- label === "unmet goals" ? "goal" : label === "active tasks" ? "task" : "bug",
11911
- record11
11912
- );
12112
+ else for (const record11 of records) printRecord(ctx, entity, record11);
11913
12113
  }
11914
12114
  });
11915
12115
  }
11916
- async function pmRemove(ctx, entity, id2) {
11917
- await pmRequest(ctx, "DELETE", `/${entity}/${encodeURIComponent(id2)}`);
11918
- ctx.out.log(`deleted ${entity} ${id2}`);
12116
+
12117
+ // src/pm-start.ts
12118
+ var getTask = async (ctx, id2) => (await pmRequest(ctx, "GET", `/task/${encodeURIComponent(id2)}`)).record;
12119
+ async function claimAt(ctx, task, parsed) {
12120
+ const res = await pmRequest(
12121
+ ctx,
12122
+ "POST",
12123
+ `/task/${encodeURIComponent(task.id)}/claim`,
12124
+ { expectedRevision: task.revision, mutationId: writeMutationId2(parsed) }
12125
+ );
12126
+ return res.record;
12127
+ }
12128
+ async function selectTask(ctx, parsed, appId, projectId) {
12129
+ const named = parsed.positionals[2];
12130
+ if (named) return getTask(ctx, named);
12131
+ const goalId = stringOpt(parsed.options.goal);
12132
+ const ready = await listFiltered(ctx, "task", appId, projectId, {
12133
+ column: READY_COLUMN,
12134
+ ...goalId ? { goalId } : {}
12135
+ });
12136
+ const candidate = ready[0];
12137
+ if (!candidate) {
12138
+ throw new Error(
12139
+ `no Ready task to claim in ${appId}${goalId ? ` for goal ${goalId}` : ""}. Run "odla-ai pm next --app ${appId}" to see open goals and Backlog work, then "odla-ai pm task ready <id>" once a task has a complete contract.`
12140
+ );
12141
+ }
12142
+ return candidate;
12143
+ }
12144
+ async function pmStart(ctx, parsed) {
12145
+ const appId = stringOpt(parsed.options.app) ?? ctx.appId;
12146
+ if (!appId) throw new Error("pm start needs --app <appId>");
12147
+ const projectId = stringOpt(parsed.options.project) ?? ctx.projectId;
12148
+ const verbose = wantsVerbose(parsed);
12149
+ const selected = await selectTask(ctx, parsed, appId, projectId);
12150
+ if (selected.column !== READY_COLUMN) {
12151
+ throw new Error(
12152
+ `task ${selected.id} is ${selected.column === "doing" ? "already claimed" : `in ${selected.column}`}, not Ready. Only Ready work can be started; run "odla-ai pm task ready ${selected.id} --expected-revision ${selected.revision}" once its goal, description, and acceptance criteria are complete.`
12153
+ );
12154
+ }
12155
+ let claimed;
12156
+ try {
12157
+ claimed = await claimAt(ctx, selected, parsed);
12158
+ } catch (error) {
12159
+ if (!isRevisionConflict(error)) throw error;
12160
+ const reloaded = await getTask(ctx, selected.id);
12161
+ if (reloaded.column !== READY_COLUMN) {
12162
+ throw new Error(
12163
+ `task ${selected.id} was taken while starting it (now ${reloaded.column}). Run "odla-ai pm start --app ${appId}" again to take the next Ready task.`
12164
+ );
12165
+ }
12166
+ claimed = await claimAt(ctx, reloaded, parsed);
12167
+ }
12168
+ const task = claimed ?? await getTask(ctx, selected.id);
12169
+ const goalId = typeof task.goalId === "string" ? task.goalId : void 0;
12170
+ const goal = goalId ? await pmRequest(ctx, "GET", `/goal/${encodeURIComponent(goalId)}`).then((res) => res.record).catch(() => null) : null;
12171
+ const result = {
12172
+ claimed: verbose ? task : leanRecord("task", task),
12173
+ goal: goal ? verbose ? goal : leanRecord("goal", goal) : null,
12174
+ next: 'work the acceptance criteria, then "odla-ai pm task done <id>" (or "odla-ai pm task release <id> --expected-revision <n>" to hand it back)'
12175
+ };
12176
+ emit2(ctx, result, () => {
12177
+ ctx.out.log(`claimed: ${studioRecordLink(ctx, "task", task)}`);
12178
+ if (goal) ctx.out.log(`goal: ${studioRecordLink(ctx, "goal", goal)}`);
12179
+ if (typeof task.acceptanceCriteria === "string") ctx.out.log(`acceptance: ${task.acceptanceCriteria}`);
12180
+ ctx.out.log(`next: ${result.next}`);
12181
+ });
11919
12182
  }
11920
12183
 
11921
12184
  // src/pm-links.ts
@@ -12389,9 +12652,13 @@ async function pmCommand(parsed, deps = {}) {
12389
12652
  throw new Error(`unknown pm project action "${action3}". Try list|add|use.`);
12390
12653
  }
12391
12654
  if (word === "next") {
12392
- assertArgs(parsed, [...COMMON_OPTIONS, "app", "project"], 2);
12655
+ assertArgs(parsed, [...COMMON_OPTIONS, "app", "project", "verbose"], 2);
12393
12656
  return pmNext(await buildContext2(parsed, deps), parsed);
12394
12657
  }
12658
+ if (word === "start") {
12659
+ assertArgs(parsed, [...COMMON_OPTIONS, "app", "project", "goal", "verbose", "mutation-id"], 3);
12660
+ return pmStart(await buildContext2(parsed, deps), parsed);
12661
+ }
12395
12662
  if (word === "watch") {
12396
12663
  assertArgs(parsed, [
12397
12664
  ...COMMON_OPTIONS,
@@ -12409,11 +12676,11 @@ async function pmCommand(parsed, deps = {}) {
12409
12676
  return pmWatch(await buildContext2(parsed, deps), parsed).then(() => void 0);
12410
12677
  }
12411
12678
  if (word === "handoff") {
12412
- assertArgs(parsed, [...COMMON_OPTIONS, "app", "project"], 2);
12679
+ assertArgs(parsed, [...COMMON_OPTIONS, "app", "project", "verbose"], 2);
12413
12680
  return pmHandoff(await buildContext2(parsed, deps), parsed);
12414
12681
  }
12415
12682
  const entity = ALIASES[word];
12416
- if (!entity) throw new Error(`unknown pm entity "${word}". Try "odla-ai pm bug list" (goal|task|decision|bug).`);
12683
+ if (!entity) throw new Error(`unknown pm entity "${word}". Try "odla-ai pm start" to claim Ready work, or "odla-ai pm bug list" (goal|task|decision|bug).`);
12417
12684
  const requestedAction = parsed.positionals[2] ?? "list";
12418
12685
  const action2 = canonicalAction(requestedAction);
12419
12686
  if (!action2) throw new Error(`unknown pm action "${requestedAction}". Try list|add|get|set|done|link|ref|comment|comments|history|rm.`);