@odla-ai/cli 0.30.0 → 0.30.2

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/bin.js CHANGED
@@ -174,7 +174,7 @@ function absoluteEntryPath(entryPath) {
174
174
 
175
175
  // src/bin.ts
176
176
  var argv = process.argv.slice(2);
177
- requireCurrentCliForProvision(argv).then(() => requireCoherentProvisionRuntime(argv)).then(async () => (await import("./cli-CKBUVTJM.js")).runCli()).catch((err) => {
177
+ requireCurrentCliForProvision(argv).then(() => requireCoherentProvisionRuntime(argv)).then(async () => (await import("./cli-BGSX5Z4C.js")).runCli()).catch((err) => {
178
178
  console.error(redactSecrets(`odla-ai: ${err instanceof Error ? err.message : String(err)}`));
179
179
  process.exitCode = exitCodeFor(err);
180
180
  });
@@ -13,7 +13,7 @@ import {
13
13
  import process8 from "process";
14
14
 
15
15
  // src/token.ts
16
- import { collectToken, OdlaError, requestToken } from "@odla-ai/db";
16
+ import { OdlaError, requestToken } from "@odla-ai/db";
17
17
  import { createHash } from "crypto";
18
18
  import process5 from "process";
19
19
 
@@ -117,12 +117,40 @@ function handshakeUrl(platformUrl, userCode) {
117
117
 
118
118
  // src/handshake-state.ts
119
119
  import { rmSync } from "fs";
120
- import { dirname as dirname2, join } from "path";
120
+ import { dirname, join } from "path";
121
121
  import process4 from "process";
122
+ function handshakeFile(cfg) {
123
+ return join(dirname(cfg.local.tokenFile), "handshake.local.json");
124
+ }
125
+ function clearPendingHandshake(path) {
126
+ rmSync(path, { force: true });
127
+ }
128
+ function minutesLeft(expiresAt) {
129
+ return Math.max(1, Math.round((expiresAt - Date.now()) / 6e4));
130
+ }
131
+ function approvalHint(pending) {
132
+ return `approve code ${pending.userCode} at ${pending.approvalUrl} (${minutesLeft(pending.expiresAt)}m left)`;
133
+ }
134
+ function approvalReminder(out, pending, periodMs = 3e4) {
135
+ const timer = setInterval(() => {
136
+ for (const line of reminderLines({
137
+ userCode: pending.userCode,
138
+ approvalUrl: pending.approvalUrl,
139
+ minutesLeft: minutesLeft(pending.expiresAt)
140
+ }))
141
+ out.log(line);
142
+ }, periodMs);
143
+ timer.unref?.();
144
+ return () => clearInterval(timer);
145
+ }
146
+ function handshakeWaitMs(waitSeconds, interactive = process4.stdout.isTTY === true) {
147
+ if (waitSeconds !== void 0) return waitSeconds * 1e3;
148
+ return interactive ? void 0 : 9e4;
149
+ }
122
150
 
123
151
  // src/local.ts
124
152
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
125
- import { dirname, isAbsolute, relative, resolve } from "path";
153
+ import { dirname as dirname2, isAbsolute, relative, resolve } from "path";
126
154
  var GITIGNORE_LINES = [".odla/*.local.json", ".odla/dev-token.json", ".dev.vars"];
127
155
  function readJsonFile(path) {
128
156
  try {
@@ -190,7 +218,7 @@ function o11yDevVars(cfg) {
190
218
  function resolveWriteDevVarsTarget(cfg, requested) {
191
219
  if (!requested) return null;
192
220
  if (requested === true) return cfg.local.devVarsFile;
193
- return resolve(dirname(cfg.configPath), requested);
221
+ return resolve(dirname2(cfg.configPath), requested);
194
222
  }
195
223
  function writeDevVars(path, credentials, env, o11y) {
196
224
  const entry = credentials.envs[env];
@@ -230,7 +258,7 @@ function isManagedDevVar(line) {
230
258
  return !!match?.[1] && MANAGED_DEV_VARS.has(match[1]);
231
259
  }
232
260
  function writePrivateText(path, text2) {
233
- mkdirSync(dirname(path), { recursive: true });
261
+ mkdirSync(dirname2(path), { recursive: true });
234
262
  const temporary = `${path}.tmp-${process.pid}-${Date.now()}`;
235
263
  writeFileSync(temporary, text2, { mode: 384 });
236
264
  chmodSync(temporary, 384);
@@ -246,58 +274,6 @@ function displayPath(path, rootDir = process.cwd()) {
246
274
  return rel && !rel.startsWith("..") ? rel : path;
247
275
  }
248
276
 
249
- // src/handshake-state.ts
250
- function handshakeFile(cfg) {
251
- return join(dirname2(cfg.local.tokenFile), "handshake.local.json");
252
- }
253
- var RESUME_MARGIN_MS = 5e3;
254
- function readPendingHandshake(path, platform, email, requiredGrant) {
255
- const pending = readJsonFile(path);
256
- if (!pending || pending.platform !== platform || pending.email !== email) return null;
257
- if (typeof pending.userCode !== "string" || typeof pending.deviceCode !== "string") return null;
258
- if (typeof pending.expiresAt !== "number" || pending.expiresAt <= Date.now() + RESUME_MARGIN_MS) return null;
259
- if (requiredGrant && !grantCovers(pending, requiredGrant)) return null;
260
- return {
261
- interval: typeof pending.interval === "number" ? pending.interval : 3,
262
- ...pending,
263
- approvalUrl: handshakeUrl(platform, pending.userCode)
264
- };
265
- }
266
- function grantCovers(stored, required) {
267
- if (required.optionalProjectCapabilities.length === 0) return true;
268
- return required.projectIds.every((id) => stored.projectIds?.includes(id)) && required.optionalProjectCapabilities.every(
269
- (capability) => stored.optionalProjectCapabilities?.includes(capability)
270
- );
271
- }
272
- function writePendingHandshake(path, pending) {
273
- writePrivateJson(path, pending);
274
- }
275
- function clearPendingHandshake(path) {
276
- rmSync(path, { force: true });
277
- }
278
- function minutesLeft(expiresAt) {
279
- return Math.max(1, Math.round((expiresAt - Date.now()) / 6e4));
280
- }
281
- function approvalHint(pending) {
282
- return `approve code ${pending.userCode} at ${pending.approvalUrl} (${minutesLeft(pending.expiresAt)}m left)`;
283
- }
284
- function approvalReminder(out, pending, periodMs = 3e4) {
285
- const timer = setInterval(() => {
286
- for (const line of reminderLines({
287
- userCode: pending.userCode,
288
- approvalUrl: pending.approvalUrl,
289
- minutesLeft: minutesLeft(pending.expiresAt)
290
- }))
291
- out.log(line);
292
- }, periodMs);
293
- timer.unref?.();
294
- return () => clearInterval(timer);
295
- }
296
- function handshakeWaitMs(waitSeconds, interactive = process4.stdout.isTTY === true) {
297
- if (waitSeconds !== void 0) return waitSeconds * 1e3;
298
- return interactive ? void 0 : 9e4;
299
- }
300
-
301
277
  // src/token.ts
302
278
  async function getDeveloperToken(cfg, options, doFetch, out, grantRequest = {}) {
303
279
  const audience = platformAudience(cfg.platformUrl);
@@ -336,8 +312,8 @@ async function getDeveloperToken(cfg, options, doFetch, out, grantRequest = {})
336
312
  grantIntent
337
313
  };
338
314
  const waitMs = handshakeWaitMs(options.wait);
339
- if (grantRequest.freshLogin) clearPendingHandshake(ctx.pendingFile);
340
- const { token, expiresAt } = await resumePendingHandshake(ctx, waitMs) ?? await freshHandshake(ctx, waitMs);
315
+ clearPendingHandshake(ctx.pendingFile);
316
+ const { token, expiresAt } = await freshHandshake(ctx, waitMs);
341
317
  clearPendingHandshake(ctx.pendingFile);
342
318
  writePrivateJson(cfg.local.tokenFile, {
343
319
  platform: audience,
@@ -350,47 +326,6 @@ async function getDeveloperToken(cfg, options, doFetch, out, grantRequest = {})
350
326
  out.error(`auth: developer token cached (${displayPath(cfg.local.tokenFile, cfg.rootDir)})`);
351
327
  return token;
352
328
  }
353
- async function resumePendingHandshake(ctx, waitMs) {
354
- const pending = readPendingHandshake(
355
- ctx.pendingFile,
356
- ctx.audience,
357
- ctx.email,
358
- ctx.grantIntent
359
- );
360
- if (!pending) return null;
361
- ctx.out.error("");
362
- ctx.out.error(`auth: resuming pending handshake \u2014 ${approvalHint(pending)}`);
363
- await presentHandshakeApproval(ctx.out, {
364
- userCode: pending.userCode,
365
- approvalUrl: pending.approvalUrl,
366
- minutesLeft: Math.max(1, Math.floor((pending.expiresAt - Date.now()) / 6e4)),
367
- purpose: `sign this terminal in as ${ctx.email}`
368
- }, ctx.options);
369
- ctx.out.error("");
370
- const stopReminder = approvalReminder(ctx.out, pending);
371
- try {
372
- return await collectToken({
373
- endpoint: ctx.cfg.platformUrl,
374
- deviceCode: pending.deviceCode,
375
- expiresAt: pending.expiresAt,
376
- interval: pending.interval,
377
- waitMs,
378
- fetch: ctx.doFetch
379
- });
380
- } catch (err) {
381
- const code = err instanceof OdlaError ? err.code : void 0;
382
- if (code === "handshake_pending") throw stillPending(pending, ctx.email);
383
- if (code === "handshake_expired" || code === "handshake_timeout") {
384
- clearPendingHandshake(ctx.pendingFile);
385
- ctx.out.error("auth: pending handshake lapsed unapproved; starting a fresh one");
386
- return null;
387
- }
388
- if (code === "handshake_denied") clearPendingHandshake(ctx.pendingFile);
389
- throw err;
390
- } finally {
391
- stopReminder();
392
- }
393
- }
394
329
  async function freshHandshake(ctx, waitMs) {
395
330
  let started;
396
331
  let stopReminder;
@@ -418,7 +353,6 @@ async function freshHandshake(ctx, waitMs) {
418
353
  projectIds: ctx.grantIntent.projectIds,
419
354
  optionalProjectCapabilities: ctx.grantIntent.optionalProjectCapabilities
420
355
  };
421
- writePendingHandshake(ctx.pendingFile, started);
422
356
  await presentHandshakeApproval(ctx.out, {
423
357
  userCode,
424
358
  approvalUrl,
@@ -454,7 +388,7 @@ function projectAgentHandle(appId) {
454
388
  function stillPending(pending, email) {
455
389
  return new OdlaError(
456
390
  "handshake_pending",
457
- `handshake still pending \u2014 ask ${email} to ${approvalHint(pending)}, then re-run this command; it resumes the same handshake and collects the token`,
391
+ `handshake still pending \u2014 ask ${email} to ${approvalHint(pending)}; if this command exits, re-run it to request a new code`,
458
392
  { retryable: true }
459
393
  );
460
394
  }
@@ -4781,28 +4715,31 @@ async function smoke(options) {
4781
4715
  const out = options.stdout ?? console;
4782
4716
  const cfg = await loadProjectConfig(options.configPath);
4783
4717
  const env = resolveEnv(cfg, options.env);
4784
- const credentials = readCredentials(cfg.local.credentialsFile);
4785
- if (!credentials) {
4718
+ const runtime = options.runtime === true;
4719
+ const credentials = runtime ? null : readCredentials(cfg.local.credentialsFile);
4720
+ if (!runtime && !credentials) {
4786
4721
  throw new Error(`local credentials missing: ${displayPath(cfg.local.credentialsFile, cfg.rootDir)}. Run "odla-ai provision --write-dev-vars".`);
4787
4722
  }
4788
- if (credentials.appId !== cfg.app.id) {
4723
+ if (credentials && credentials.appId !== cfg.app.id) {
4789
4724
  throw new Error(`local credentials are for app "${credentials.appId}", but config app is "${cfg.app.id}"`);
4790
4725
  }
4791
- const entry = credentials.envs[env];
4792
- if (!entry?.tenantId) {
4726
+ const entry = credentials?.envs[env];
4727
+ if (!runtime && !entry?.tenantId) {
4793
4728
  throw new Error(`local credentials have no tenant for env "${env}". Run "odla-ai provision --write-dev-vars".`);
4794
4729
  }
4795
4730
  const hasDb = cfg.services.includes("db");
4796
4731
  const hasO11y = cfg.services.includes("o11y");
4797
- if (hasDb && !entry.dbKey) {
4732
+ if (!runtime && hasDb && !entry?.dbKey) {
4798
4733
  throw new Error(`local credentials have no db key for env "${env}". Run "odla-ai provision --write-dev-vars".`);
4799
4734
  }
4800
- if (hasO11y && !entry.o11yToken) {
4735
+ if (!runtime && hasO11y && !entry?.o11yToken) {
4801
4736
  throw new Error(`local credentials have no o11y token for env "${env}". Run "odla-ai provision --write-dev-vars".`);
4802
4737
  }
4803
4738
  const doFetch = options.fetch ?? fetch;
4739
+ const tenantId = entry?.tenantId ?? resolveTenant(cfg, env).tenant;
4804
4740
  out.log(`smoke: ${cfg.app.id}/${env}`);
4805
- out.log(` tenant: ${entry.tenantId}`);
4741
+ out.log(` mode: ${runtime ? "runtime (Worker-held credentials)" : "local credentials"}`);
4742
+ out.log(` tenant: ${tenantId}`);
4806
4743
  const publicConfig = await getJson(doFetch, publicConfigUrl(cfg.platformUrl, cfg.app.id, env), void 0);
4807
4744
  out.log(` public-config: ok`);
4808
4745
  if (cfg.services.includes("ai") && cfg.ai?.provider) {
@@ -4816,7 +4753,7 @@ async function smoke(options) {
4816
4753
  if (mode !== "hosted") throw new Error(`ai mode mismatch: expected "hosted", public-config has "${String(mode ?? "none")}"`);
4817
4754
  out.log(" ai: hosted");
4818
4755
  }
4819
- if (hasO11y) out.log(` o11y: credentials present`);
4756
+ if (hasO11y) out.log(runtime ? ` o11y: Worker-held credential` : ` o11y: credentials present`);
4820
4757
  if (cfg.services.includes("calendar")) {
4821
4758
  const token = await getDeveloperToken(
4822
4759
  cfg,
@@ -4835,10 +4772,10 @@ async function smoke(options) {
4835
4772
  out.log(` calendar: ${status.status}, bookable (booking \u2192 ${status.bookingCalendarId ?? "primary"})`);
4836
4773
  }
4837
4774
  const database = await resolveDatabaseConfig(cfg);
4838
- if (hasDb) {
4775
+ if (hasDb && !runtime) {
4839
4776
  const expectedSchema = database.schema;
4840
4777
  const expectedEntities = serializedEntities(expectedSchema);
4841
- const liveSchemaPayload = await getJson(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(entry.tenantId)}/schema`, entry.dbKey);
4778
+ const liveSchemaPayload = await getJson(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(tenantId)}/schema`, entry.dbKey);
4842
4779
  const liveSchema = liveSchemaPayload.schema ?? liveSchemaPayload;
4843
4780
  const liveEntities = serializedEntities(liveSchema);
4844
4781
  if (expectedEntities.length) {
@@ -4848,7 +4785,7 @@ async function smoke(options) {
4848
4785
  out.log(` schema: ${liveEntities.length} entities`);
4849
4786
  const aggregateEntity = expectedEntities[0] ?? liveEntities[0];
4850
4787
  if (aggregateEntity) {
4851
- const aggregate = await postJson2(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(entry.tenantId)}/aggregate`, entry.dbKey, {
4788
+ const aggregate = await postJson2(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(tenantId)}/aggregate`, entry.dbKey, {
4852
4789
  ns: aggregateEntity,
4853
4790
  aggregate: { count: true }
4854
4791
  });
@@ -4857,9 +4794,20 @@ async function smoke(options) {
4857
4794
  } else {
4858
4795
  out.log(` aggregate: skipped (schema has no entities)`);
4859
4796
  }
4797
+ } else if (hasDb) {
4798
+ out.log(` db: Worker-held credential (direct schema/aggregate skipped)`);
4860
4799
  } else {
4861
4800
  out.log(` db: skipped (not enabled)`);
4862
4801
  }
4802
+ if (runtime) {
4803
+ const link = cfg.links?.[env];
4804
+ if (!link) throw new Error(`runtime smoke requires links.${env} in odla.config.mjs`);
4805
+ const res = await doFetch(link, { redirect: "manual" });
4806
+ if (res.status < 200 || res.status >= 500) {
4807
+ throw new Error(`runtime target ${new URL(link).origin} returned ${res.status}`);
4808
+ }
4809
+ out.log(` runtime target: ${res.status}`);
4810
+ }
4863
4811
  const probes = database.integrations.flatMap(
4864
4812
  (integration) => (integration.probes ?? []).map((probe) => ({ integration: integration.id, probe }))
4865
4813
  );
@@ -5062,10 +5010,11 @@ async function projectCommand(command, parsed, deps) {
5062
5010
  return true;
5063
5011
  }
5064
5012
  if (command === "smoke") {
5065
- assertArgs(parsed, ["config", "env", "token", "email", "open"], 1);
5013
+ assertArgs(parsed, ["config", "env", "runtime", "token", "email", "open"], 1);
5066
5014
  await smoke({
5067
5015
  configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs",
5068
5016
  env: stringOpt(parsed.options.env),
5017
+ runtime: parsed.options.runtime === true,
5069
5018
  token: stringOpt(parsed.options.token),
5070
5019
  email: stringOpt(parsed.options.email),
5071
5020
  open: parsed.options.open === false ? false : parsed.options.open === true ? true : void 0,
@@ -8840,6 +8789,7 @@ Usage:
8840
8789
  odla-ai pm bug add --app <id> --title <t> (--description <text>|--body <text>) [--status <s>] [--severity <s>] [--goal <id>] [--assignee <id>] [--decision <id>] [--mutation-id <id>] [--json]
8841
8790
  odla-ai bug report --app <id> --title <t> (--description <text>|--body <text>) [--severity <s>] [--json]
8842
8791
  odla-ai pm <goal|task|decision|bug> get <id> [--json]
8792
+ odla-ai pm <goal|task|decision|bug> link <id> [--json]
8843
8793
  odla-ai pm <goal|task|decision|bug> ref <id> [--json]
8844
8794
  odla-ai pm goal set <id> [--title <t>|--status <s>|--proof <text>|--no-proof|--target <pct>|--no-target] [--mutation-id <id>] [--json]
8845
8795
  odla-ai pm task set <id> [--title <t>|--column <backlog|ready|doing|review|done>|--rank <n>|--goal <id>|--no-goal|--alignment-decision <id>|--no-alignment-decision|--execution <human|agent|either>|--assignee <id>|--no-assignee|--description <text>|--body <text>|--acceptance <text>|--no-acceptance|--due <epoch-ms>|--no-due|--expected-revision <n>] [--mutation-id <id>] [--json]
@@ -8908,7 +8858,7 @@ Usage:
8908
8858
  odla-ai provision [--live] [--config odla.config.mjs] [--email <odla-account>] [--request-grant] [--wait <seconds>] [--dry-run] [--push-secrets] [--rotate-o11y-token] [--write-dev-vars[=path]] [--yes]
8909
8859
  odla-ai credentials list [--config odla.config.mjs] [--env dev] [--all] [--json]
8910
8860
  odla-ai credentials revoke <receipt-id> [--config odla.config.mjs] [--json]
8911
- odla-ai smoke [--config odla.config.mjs] [--env dev] [--email <odla-account>] [--no-open]
8861
+ odla-ai smoke [--config odla.config.mjs] [--env dev] [--runtime] [--email <odla-account>] [--no-open]
8912
8862
  odla-ai skill install [--dir <project>] [--agent <name>] [--global] [--force]
8913
8863
  odla-ai secrets push --env <env> [--config odla.config.mjs] [--dry-run] [--yes]
8914
8864
  odla-ai secrets set <name> --env <env> (--from-env <NAME>|--stdin) [--email <odla-account>] [--config odla.config.mjs] [--yes]
@@ -9010,7 +8960,7 @@ Commands:
9010
8960
  "provision --live --yes" initializes only the live instance of
9011
8961
  an existing sandbox app and enables every configured service;
9012
8962
  no edit to the dev-first envs list is required.
9013
- smoke Verify credentials, public-config, composed schema, db aggregate, and integration probes.
8963
+ smoke Verify local service access, or use --runtime for a Worker-held credential deployment.
9014
8964
  skill Same installer; --agent accepts all, claude, codex, cursor,
9015
8965
  copilot, gemini, or agents (repeatable or comma-separated).
9016
8966
  secrets Push configured db/o11y secrets into the Worker via wrangler
@@ -9050,12 +9000,11 @@ Safety:
9050
9000
  attempts to open it \u2014 including in CI, SSH, display-less, and agent-driven
9051
9001
  shells. Only --no-open suppresses the attempt. Browser launch is best-effort:
9052
9002
  agents with browser control must open that exact URL immediately; otherwise
9053
- they must give it to the human verbatim. A started handshake is persisted
9054
- under .odla/, so a command killed mid-wait loses nothing \u2014 rerunning resumes
9055
- the same code. Outside an interactive terminal the wait is capped (90s by
9003
+ they must give it to the human verbatim. A device code remains only in the
9004
+ running process. Outside an interactive terminal the wait is capped (90s by
9056
9005
  default, --wait <seconds> to change); a still-pending handshake then exits
9057
- with code 75: open the same URL (or relay it if browser control is unavailable),
9058
- wait for approval, and re-run to collect.
9006
+ with code 75. Rerunning always requests and opens a new code; older clients'
9007
+ persisted pending state is discarded.
9059
9008
  A fresh device handshake requires --email <odla-account> or ODLA_USER_EMAIL.
9060
9009
  The email is a non-secret identity hint: never provide a password or session
9061
9010
  token. It is the email shown by the signed-in odla account \u2014 never infer it
@@ -9706,9 +9655,25 @@ function referenceMarkup(entity, record10) {
9706
9655
  const label = (record10.title?.trim() || `${entity} ${record10.id}`).replaceAll("]", ")");
9707
9656
  return `@[${label}](pm:${entity}/${record10.id})`;
9708
9657
  }
9658
+ var STUDIO_SECTION = {
9659
+ goal: "goals",
9660
+ task: "board",
9661
+ decision: "decisions",
9662
+ bug: "bugs"
9663
+ };
9664
+ function studioRecordUrl(ctx, entity, id) {
9665
+ return new URL(
9666
+ `/studio/pm/${STUDIO_SECTION[entity]}/${encodeURIComponent(id)}`,
9667
+ ctx.platformUrl
9668
+ ).href;
9669
+ }
9670
+ function studioRecordLink(ctx, entity, record10) {
9671
+ const label = (record10.title?.trim() || `${entity} ${record10.id}`).replaceAll("]", ")");
9672
+ return `[${label}](${studioRecordUrl(ctx, entity, record10.id)})`;
9673
+ }
9709
9674
  function printRecord(ctx, entity, record10) {
9710
9675
  ctx.out.log(
9711
- `${record10.id} [${statusCol(entity, record10)}] ${record10.appId} ${record10.title ?? ""}`
9676
+ `${record10.id} [${statusCol(entity, record10)}] ${record10.appId} ${studioRecordLink(ctx, entity, record10)}`
9712
9677
  );
9713
9678
  }
9714
9679
  function emit2(ctx, value2, human) {
@@ -9758,7 +9723,8 @@ async function pmAdd(ctx, entity, parsed) {
9758
9723
  input,
9759
9724
  mutationId: writeMutationId2(parsed)
9760
9725
  });
9761
- emit2(ctx, res, () => ctx.out.log(`created ${entity} ${res.id}`));
9726
+ const record10 = { id: res.id, appId, title: String(input.title) };
9727
+ emit2(ctx, res, () => ctx.out.log(`created ${entity}: ${studioRecordLink(ctx, entity, record10)}`));
9762
9728
  }
9763
9729
  async function pmGet(ctx, entity, id) {
9764
9730
  const { record: record10 } = await pmRequest(ctx, "GET", `/${entity}/${encodeURIComponent(id)}`);
@@ -9783,7 +9749,10 @@ async function pmSet(ctx, entity, id, parsed) {
9783
9749
  patch: patch2,
9784
9750
  mutationId: writeMutationId2(parsed)
9785
9751
  });
9786
- emit2(ctx, res, () => ctx.out.log(res.record ? `${res.record.id} [${statusCol(entity, res.record)}] ${res.record.appId} ${res.record.title ?? ""}` : `updated ${entity} ${id}`));
9752
+ emit2(ctx, res, () => {
9753
+ if (!res.record) return ctx.out.log(`updated ${entity} ${id}`);
9754
+ ctx.out.log(`${entity}: ${studioRecordLink(ctx, entity, res.record)} \u2192 ${statusCol(entity, res.record)}`);
9755
+ });
9787
9756
  }
9788
9757
  async function pmDone(ctx, entity, id, parsed) {
9789
9758
  const decisionId = stringOpt(parsed.options.decision);
@@ -9793,7 +9762,11 @@ async function pmDone(ctx, entity, id, parsed) {
9793
9762
  patch: patch2,
9794
9763
  mutationId: writeMutationId2(parsed)
9795
9764
  });
9796
- emit2(ctx, res, () => ctx.out.log(`${entity} ${id} \u2192 done`));
9765
+ emit2(ctx, res, () => {
9766
+ const label = res.record ? studioRecordLink(ctx, entity, res.record) : id;
9767
+ const state2 = res.record ? statusCol(entity, res.record) : "done";
9768
+ ctx.out.log(`${entity}: ${label} \u2192 ${state2}`);
9769
+ });
9797
9770
  }
9798
9771
  async function pmTaskLifecycle(ctx, id, action2, parsed) {
9799
9772
  const rawRevision = stringOpt(parsed.options["expected-revision"]);
@@ -9822,7 +9795,8 @@ async function pmTaskLifecycle(ctx, id, action2, parsed) {
9822
9795
  );
9823
9796
  emit2(ctx, res, () => {
9824
9797
  const state2 = res.record ? statusCol("task", res.record) : action2;
9825
- ctx.out.log(`task ${id} \u2192 ${state2}`);
9798
+ const label = res.record ? studioRecordLink(ctx, "task", res.record) : id;
9799
+ ctx.out.log(`task: ${label} \u2192 ${state2}`);
9826
9800
  });
9827
9801
  }
9828
9802
  async function allRecords(ctx, entity, appId) {
@@ -9919,6 +9893,20 @@ async function pmRemove(ctx, entity, id) {
9919
9893
  ctx.out.log(`deleted ${entity} ${id}`);
9920
9894
  }
9921
9895
 
9896
+ // src/pm-links.ts
9897
+ async function pmLink(ctx, entity, id) {
9898
+ const { record: record10 } = await pmRequest(
9899
+ ctx,
9900
+ "GET",
9901
+ `/${entity}/${encodeURIComponent(id)}`
9902
+ );
9903
+ const url = studioRecordUrl(ctx, entity, record10.id);
9904
+ const markdown = studioRecordLink(ctx, entity, record10);
9905
+ emit2(ctx, { kind: entity, id: record10.id, label: record10.title ?? "", url, markdown }, () => {
9906
+ ctx.out.log(markdown);
9907
+ });
9908
+ }
9909
+
9922
9910
  // src/pm-comments.ts
9923
9911
  async function pmComment(ctx, entity, id, parsed) {
9924
9912
  const body = stringOpt(parsed.options.body);
@@ -10166,6 +10154,7 @@ var ACTION_OPTIONS = {
10166
10154
  ready: ["goal", "alignment-decision", "execution", "description", "desc", "body", "acceptance", "expected-revision", "mutation-id"],
10167
10155
  claim: ["expected-revision", "mutation-id"],
10168
10156
  release: ["expected-revision", "mutation-id"],
10157
+ link: [],
10169
10158
  ref: []
10170
10159
  };
10171
10160
  var ENTITY_OPTIONS = {
@@ -10270,7 +10259,7 @@ async function pmCommand(parsed, deps = {}) {
10270
10259
  if (!entity) throw new Error(`unknown pm entity "${word}". Try "odla-ai pm bug list" (goal|task|decision|bug).`);
10271
10260
  const requestedAction = parsed.positionals[2] ?? "list";
10272
10261
  const action2 = canonicalAction(requestedAction);
10273
- if (!action2) throw new Error(`unknown pm action "${requestedAction}". Try list|add|get|set|done|comment|comments|rm.`);
10262
+ if (!action2) throw new Error(`unknown pm action "${requestedAction}". Try list|add|get|set|done|link|ref|comment|comments|rm.`);
10274
10263
  assertArgs(parsed, allowedOptions(entity, action2), 4);
10275
10264
  if ((action2 === "ready" || action2 === "claim" || action2 === "release") && entity !== "task") {
10276
10265
  throw new Error(`pm ${action2} is only valid for tasks`);
@@ -10294,6 +10283,8 @@ async function pmCommand(parsed, deps = {}) {
10294
10283
  return pmComments(ctx, entity, requireId2(id, action2));
10295
10284
  case "rm":
10296
10285
  return pmRemove(ctx, entity, requireId2(id, action2));
10286
+ case "link":
10287
+ return pmLink(ctx, entity, requireId2(id, action2));
10297
10288
  case "ref":
10298
10289
  return pmReference(ctx, entity, requireId2(id, action2));
10299
10290
  case "ready":
@@ -13246,4 +13237,4 @@ export {
13246
13237
  isTerminalHostedSecurityStatus,
13247
13238
  runCli
13248
13239
  };
13249
- //# sourceMappingURL=chunk-PPSXHZJI.js.map
13240
+ //# sourceMappingURL=chunk-3HFNKDZV.js.map