pinsay-cli 0.6.10 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli.js +314 -61
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -15,7 +15,7 @@ var init_build_constants = __esm({
15
15
  "src/build-constants.ts"() {
16
16
  "use strict";
17
17
  BUILD_DEFAULT_SERVER = true ? "https://app.pinsay.dev" : "https://app.pinsay.dev";
18
- BUILD_CLI_VERSION = true ? "0.6.10" : "0.0.0-dev";
18
+ BUILD_CLI_VERSION = true ? "0.7.1" : "0.0.0-dev";
19
19
  }
20
20
  });
21
21
 
@@ -705,7 +705,7 @@ var init_map = __esm({
705
705
  });
706
706
 
707
707
  // src/auth.ts
708
- import { promises as fs17 } from "node:fs";
708
+ import { promises as fs18 } from "node:fs";
709
709
  import { dirname as dirname8 } from "node:path";
710
710
  async function readApiKey(cwd2, server) {
711
711
  const { key } = await resolveApiKey(cwd2, server);
@@ -730,7 +730,7 @@ async function resolveToken(server, cwd2, explicitApiKey) {
730
730
  return void 0;
731
731
  const cacheFile = tokenCacheFile(server, apiKey);
732
732
  try {
733
- const cached = JSON.parse(await fs17.readFile(cacheFile, "utf8"));
733
+ const cached = JSON.parse(await fs18.readFile(cacheFile, "utf8"));
734
734
  const token = typeof cached?.token === "string" ? cached.token.trim() : "";
735
735
  if (token && !isJwtExpired(token))
736
736
  return token;
@@ -747,8 +747,8 @@ async function resolveToken(server, cwd2, explicitApiKey) {
747
747
  );
748
748
  if (login?.token) {
749
749
  try {
750
- await fs17.mkdir(dirname8(cacheFile), { recursive: true });
751
- await fs17.writeFile(cacheFile, JSON.stringify({ token: login.token }), "utf8");
750
+ await fs18.mkdir(dirname8(cacheFile), { recursive: true });
751
+ await fs18.writeFile(cacheFile, JSON.stringify({ token: login.token }), "utf8");
752
752
  } catch {
753
753
  }
754
754
  return login.token;
@@ -770,7 +770,7 @@ var init_auth = __esm({
770
770
 
771
771
  // src/vite/resolve.ts
772
772
  import { existsSync as existsSync3, readFileSync } from "node:fs";
773
- import { join as join17 } from "node:path";
773
+ import { join as join18 } from "node:path";
774
774
  function entryOf(json, hash) {
775
775
  if (!json || typeof json !== "object")
776
776
  return void 0;
@@ -799,11 +799,11 @@ function resolveSource(cwd2, hash) {
799
799
  const miss = { kind: "unknown", path: null, component: null };
800
800
  if (!hash || !/^[0-9a-f]{8}$/.test(hash))
801
801
  return miss;
802
- const manifestPath = join17(cwd2, ".pinsay", "manifest.json");
802
+ const manifestPath = join18(cwd2, ".pinsay", "manifest.json");
803
803
  const current = normalise(entryOf(readJson2(manifestPath), hash));
804
804
  if (current)
805
805
  return { kind: "manifest", path: current.path, component: current.component };
806
- const prevPath = join17(cwd2, ".pinsay", "manifest.prev.json");
806
+ const prevPath = join18(cwd2, ".pinsay", "manifest.prev.json");
807
807
  const previous = normalise(entryOf(readJson2(prevPath), hash));
808
808
  if (previous) {
809
809
  return {
@@ -1103,6 +1103,12 @@ async function statusCommand(cwd2, parsed, positionals = []) {
1103
1103
  console.error(`Invalid status: ${newStatusStr}. Must be open, ready, applied, or archived.`);
1104
1104
  process.exit(2);
1105
1105
  }
1106
+ if (statusNum === 3) {
1107
+ console.error(
1108
+ 'Marking a comment as \'applied\' is not permitted via `pinsay status`.\nUse `pinsay apply --mark <id> --reply "<explanation>"` (or `pinsay reply <id> "<text>"`) to record a properly structured reply.'
1109
+ );
1110
+ process.exit(2);
1111
+ }
1106
1112
  await api(server, `/api/comments/${id}`, {
1107
1113
  method: "PATCH",
1108
1114
  body: { status: statusNum },
@@ -4741,14 +4747,66 @@ async function updateCommand(cwd2, options) {
4741
4747
  // src/commands/apply.ts
4742
4748
  init_config();
4743
4749
  init_api();
4750
+
4751
+ // src/lib/notify-update.ts
4752
+ import { promises as fs17 } from "node:fs";
4753
+ import { join as join17 } from "node:path";
4754
+ init_build_constants();
4755
+ async function checkAndNotifyUpdates(options) {
4756
+ const result = {
4757
+ cliUpdateAvailable: false,
4758
+ skillsUpdateAvailable: false
4759
+ };
4760
+ if (options.silent || !options.meta)
4761
+ return result;
4762
+ const meta = options.meta;
4763
+ if (meta.latestCliVersion && compareSemver(BUILD_CLI_VERSION, meta.latestCliVersion) < 0) {
4764
+ result.cliUpdateAvailable = true;
4765
+ console.error(
4766
+ `\x1B[33m\u{1F4A1} Update available for pinsay-cli: ${BUILD_CLI_VERSION} \u2192 ${meta.latestCliVersion}\x1B[0m
4767
+ \x1B[33m Run 'npx -y pinsay@latest' to update.\x1B[0m
4768
+ `
4769
+ );
4770
+ }
4771
+ if (meta.skillVersion && options.config && options.cwd) {
4772
+ try {
4773
+ const files = skillFilesFor(options.config);
4774
+ let hasStale = false;
4775
+ for (const rel of files) {
4776
+ const abs = join17(options.cwd, rel);
4777
+ try {
4778
+ await fs17.access(abs);
4779
+ const installed = await readStamp(abs);
4780
+ if (installed && installed !== meta.skillVersion) {
4781
+ hasStale = true;
4782
+ break;
4783
+ }
4784
+ } catch {
4785
+ }
4786
+ }
4787
+ if (hasStale) {
4788
+ result.skillsUpdateAvailable = true;
4789
+ console.error(
4790
+ `\x1B[33m\u{1F4A1} Local AI skills are out of date with the server.\x1B[0m
4791
+ \x1B[33m Run 'npx pinsay-cli update' to refresh your prompt skills.\x1B[0m
4792
+ `
4793
+ );
4794
+ }
4795
+ } catch {
4796
+ }
4797
+ }
4798
+ return result;
4799
+ }
4800
+
4801
+ // src/commands/apply.ts
4744
4802
  init_auth();
4745
4803
  init_build_constants();
4746
4804
 
4747
4805
  // src/apply/run.ts
4748
4806
  init_resolve();
4749
4807
  init_api();
4750
- import { promises as fs19 } from "node:fs";
4751
- import { join as join19, dirname as dirname9 } from "node:path";
4808
+ import { promises as fs20 } from "node:fs";
4809
+ import { join as join20, dirname as dirname9 } from "node:path";
4752
4810
  import { spawnSync } from "node:child_process";
4753
4811
 
4754
4812
  // src/apply/queue.ts
@@ -4906,8 +4964,8 @@ async function fetchQueue(ctx, filter) {
4906
4964
 
4907
4965
  // src/apply/context.ts
4908
4966
  init_api();
4909
- import { promises as fs18 } from "node:fs";
4910
- import { join as join18 } from "node:path";
4967
+ import { promises as fs19 } from "node:fs";
4968
+ import { join as join19 } from "node:path";
4911
4969
  init_config();
4912
4970
  async function loadProjectContext(ctx) {
4913
4971
  const branding = await getBranding(ctx.server);
@@ -4918,7 +4976,7 @@ async function loadProjectContext(ctx) {
4918
4976
  if (config.delegation === "off")
4919
4977
  delegation = "off";
4920
4978
  const relPath = isMultiProject(config) ? stackFileRelPath(ctx.project) : stackFileRelPath();
4921
- const stackRaw = await fs18.readFile(join18(ctx.cwd, relPath), "utf8");
4979
+ const stackRaw = await fs19.readFile(join19(ctx.cwd, relPath), "utf8");
4922
4980
  stack = JSON.parse(stackRaw);
4923
4981
  } catch {
4924
4982
  }
@@ -5235,22 +5293,34 @@ function buildApplyPrompt(items, context, opts) {
5235
5293
  }
5236
5294
  lines.push("## When you finish an item");
5237
5295
  lines.push(
5238
- 'Run exactly: `npx pinsay-cli apply --mark <id> --reply "<what changed>" --model <your-model-id> --tool <your-tool-name>`'
5296
+ 'Run exactly: `npx pinsay-cli apply --mark <id> --reply "<what changed>" --models "<planner-id>=planner,<worker-id>=implementer,<reviewer-id>=reviewer" --tool <your-tool-name>`'
5297
+ );
5298
+ lines.push(
5299
+ '(Separate style: after each item; Single style: run `npx pinsay-cli apply --mark all --reply "..." --models "..." --tool <your-tool-name>`'
5239
5300
  );
5240
5301
  lines.push(
5241
- '(Separate style: after each item; Single style: run `npx pinsay-cli apply --mark all --reply "..." --model <your-model-id> --tool <your-tool-name>`'
5302
+ "once at the end). Never run git push. `--models` is MANDATORY and MUST list EVERY model that touched the item and its role:"
5242
5303
  );
5243
5304
  lines.push(
5244
- "once at the end). Never run git push. `--model` (e.g. `claude-sonnet-5`, `gpt-5.2`) and `--tool` (e.g."
5305
+ "the planner/reviewer (your own model id) and each worker model you delegated to (`implementer`). NEVER guess or hardcode"
5245
5306
  );
5246
5307
  lines.push(
5247
- "`claude-code`, `opencode`, `cursor`, `windsurf`, `antigravity`) record which model and agent you are"
5308
+ 'outdated model names from memory (like "gemini-2.5-pro"). Always inspect your active runtime model name directly. Use exact'
5248
5309
  );
5249
5310
  lines.push(
5250
- "running as. ALWAYS pass both explicitly, even on a project you ran `init` on \u2014 `--tool` silently falls back"
5311
+ 'model ids (e.g. `claude-opus-5-5`, `claude-sonnet-5-5`, `gpt-5.2`), never aliases like "haiku" or "sonnet". With one'
5251
5312
  );
5252
5313
  lines.push(
5253
- "to whichever tool happened to run `init`, which is wrong the moment a different tool applies a comment later."
5314
+ "model and no delegation, pass just yours as `<your-model-id>=implementer`. Explicit role `=<role>` is required on each entry."
5315
+ );
5316
+ lines.push(
5317
+ "`--tool` (e.g. `claude-code`, `opencode`, `cursor`, `windsurf`, `antigravity`) records which agent you are running as."
5318
+ );
5319
+ lines.push(
5320
+ "ALWAYS pass both explicitly, even on a project you ran `init` on \u2014 `--tool` silently falls back to whichever tool happened"
5321
+ );
5322
+ lines.push(
5323
+ "to run `init`, which is wrong the moment a different tool applies a comment later."
5254
5324
  );
5255
5325
  return lines.join("\n") + "\n";
5256
5326
  }
@@ -5278,10 +5348,10 @@ function detectAiTool(override) {
5278
5348
  async function ensureToolRegistered(ctx, tool) {
5279
5349
  const config = await readConfig(ctx.cwd).catch(() => ({}));
5280
5350
  const relStackPath = isMultiProject(config) ? stackFileRelPath(ctx.project) : stackFileRelPath();
5281
- const stackPath = join19(ctx.cwd, relStackPath);
5351
+ const stackPath = join20(ctx.cwd, relStackPath);
5282
5352
  let stackData = {};
5283
5353
  try {
5284
- const raw = await fs19.readFile(stackPath, "utf8");
5354
+ const raw = await fs20.readFile(stackPath, "utf8");
5285
5355
  stackData = JSON.parse(raw);
5286
5356
  } catch {
5287
5357
  }
@@ -5300,8 +5370,8 @@ async function ensureToolRegistered(ctx, tool) {
5300
5370
  }
5301
5371
  );
5302
5372
  if (res) {
5303
- await fs19.mkdir(dirname9(stackPath), { recursive: true });
5304
- await fs19.writeFile(stackPath, JSON.stringify(res, null, 2) + "\n", "utf8");
5373
+ await fs20.mkdir(dirname9(stackPath), { recursive: true });
5374
+ await fs20.writeFile(stackPath, JSON.stringify(res, null, 2) + "\n", "utf8");
5305
5375
  return;
5306
5376
  }
5307
5377
  } catch {
@@ -5310,8 +5380,8 @@ async function ensureToolRegistered(ctx, tool) {
5310
5380
  aiTools.push(tool);
5311
5381
  stackData.aiTools = aiTools;
5312
5382
  try {
5313
- await fs19.mkdir(dirname9(stackPath), { recursive: true });
5314
- await fs19.writeFile(stackPath, JSON.stringify(stackData, null, 2) + "\n", "utf8");
5383
+ await fs20.mkdir(dirname9(stackPath), { recursive: true });
5384
+ await fs20.writeFile(stackPath, JSON.stringify(stackData, null, 2) + "\n", "utf8");
5315
5385
  } catch {
5316
5386
  }
5317
5387
  }
@@ -5385,9 +5455,9 @@ async function runApply(options, ctx) {
5385
5455
  console.error(`Failed to spawn opencode: ${res.error.message}`);
5386
5456
  }
5387
5457
  } else if (tool === "cursor") {
5388
- const promptFile = join19(ctx.cwd, ".pinsay/apply-prompt.md");
5389
- await fs19.mkdir(join19(ctx.cwd, ".pinsay"), { recursive: true });
5390
- await fs19.writeFile(promptFile, prompt, "utf8");
5458
+ const promptFile = join20(ctx.cwd, ".pinsay/apply-prompt.md");
5459
+ await fs20.mkdir(join20(ctx.cwd, ".pinsay"), { recursive: true });
5460
+ await fs20.writeFile(promptFile, prompt, "utf8");
5391
5461
  console.log(`Saved apply prompt to ${promptFile}`);
5392
5462
  } else if (tool === "clipboard") {
5393
5463
  const copied = copyToClipboard(prompt);
@@ -5475,6 +5545,106 @@ function commitUrlFor(sha, remoteUrl) {
5475
5545
  return null;
5476
5546
  }
5477
5547
 
5548
+ // src/apply/models.ts
5549
+ var MODEL_ROLES = ["planner", "implementer", "reviewer"];
5550
+ var MAX_AI_MODELS = 8;
5551
+ var MODEL_RE = /^[A-Za-z0-9][A-Za-z0-9._:/+-]*$/;
5552
+ var ModelsError = class extends Error {
5553
+ };
5554
+ function normalizeEntry(raw) {
5555
+ const model = typeof raw.model === "string" ? raw.model.trim() : "";
5556
+ if (!model || model.length > 64 || !MODEL_RE.test(model)) {
5557
+ throw new ModelsError(
5558
+ `Invalid model id "${String(raw.model ?? "")}": use the exact model id (1-64 chars, letters/digits and . _ : / + -), e.g. "claude-opus-5-5".`
5559
+ );
5560
+ }
5561
+ let role = null;
5562
+ if (raw.role !== void 0 && raw.role !== null && raw.role !== "") {
5563
+ const r = String(raw.role).trim().toLowerCase();
5564
+ if (!MODEL_ROLES.includes(r)) {
5565
+ throw new ModelsError(
5566
+ `Invalid role "${String(raw.role)}" for model "${model}": expected one of ${MODEL_ROLES.join(", ")}.`
5567
+ );
5568
+ }
5569
+ role = r;
5570
+ }
5571
+ return { model, role };
5572
+ }
5573
+ function parseModelsFlag(value) {
5574
+ const out = [];
5575
+ for (const part of value.split(",")) {
5576
+ const p = part.trim();
5577
+ if (!p)
5578
+ continue;
5579
+ const eq = p.indexOf("=");
5580
+ out.push(
5581
+ eq === -1 ? normalizeEntry({ model: p }) : normalizeEntry({ model: p.slice(0, eq), role: p.slice(eq + 1) })
5582
+ );
5583
+ }
5584
+ return out;
5585
+ }
5586
+ function dedupeModels(entries) {
5587
+ const seen = /* @__PURE__ */ new Set();
5588
+ const out = [];
5589
+ for (const e of entries) {
5590
+ const key = `${e.model.toLowerCase()}|${e.role ?? ""}`;
5591
+ if (seen.has(key))
5592
+ continue;
5593
+ seen.add(key);
5594
+ out.push(e);
5595
+ }
5596
+ if (out.length > MAX_AI_MODELS) {
5597
+ throw new ModelsError(`Too many models (${out.length}); at most ${MAX_AI_MODELS} entries are allowed.`);
5598
+ }
5599
+ return out;
5600
+ }
5601
+ function mergeModels(...sources) {
5602
+ return dedupeModels(sources.flatMap((s) => s ?? []));
5603
+ }
5604
+ function primaryModel(entries) {
5605
+ return (entries.find((e) => e.role === "implementer") ?? entries[0])?.model;
5606
+ }
5607
+ function resolveModels(flags, env = process.env) {
5608
+ const explicit = !!(flags.models || flags.model);
5609
+ const modelsStr = explicit ? flags.models : env["PINSAY_AI_MODELS"];
5610
+ const modelStr = explicit ? flags.model : env["PINSAY_AI_MODEL"];
5611
+ return mergeModels(
5612
+ modelsStr ? parseModelsFlag(modelsStr) : void 0,
5613
+ modelStr ? [normalizeEntry({ model: modelStr })] : void 0
5614
+ );
5615
+ }
5616
+ function resolveModelsFromArgs(args, env = process.env) {
5617
+ const hasArray = args?.models !== void 0 && args.models !== null;
5618
+ const single = typeof args?.model === "string" && args.model ? args.model : void 0;
5619
+ if (!hasArray && !single) {
5620
+ return resolveModels({}, env);
5621
+ }
5622
+ let fromArray;
5623
+ if (hasArray) {
5624
+ if (!Array.isArray(args.models))
5625
+ throw new ModelsError("models must be an array of {model, role?}.");
5626
+ fromArray = args.models.map(
5627
+ (m) => typeof m === "string" ? normalizeEntry({ model: m }) : normalizeEntry(m ?? {})
5628
+ );
5629
+ }
5630
+ return mergeModels(fromArray, single ? [normalizeEntry({ model: single })] : void 0);
5631
+ }
5632
+ function modelsBody(entries) {
5633
+ if (entries.length === 0)
5634
+ return {};
5635
+ return { aiModels: entries, aiModel: primaryModel(entries) };
5636
+ }
5637
+ function distinctModelCount(entries) {
5638
+ return new Set(entries.map((e) => e.model.toLowerCase())).size;
5639
+ }
5640
+ var DELEGATION_WARNING = '\u26A0 Delegation is on for this project, but fewer than 2 distinct models were reported. Pass every model that worked on the item (planner, worker, reviewer) via --models "<planner>=planner,<worker>=implementer,<reviewer>=reviewer".';
5641
+ function delegationWarning(delegation, entries) {
5642
+ return delegation === "auto" && distinctModelCount(entries) < 2 ? DELEGATION_WARNING : null;
5643
+ }
5644
+ function describeModels(entries) {
5645
+ return entries.length === 0 ? "(none)" : entries.map((e) => `${e.model}${e.role ? `=${e.role}` : ""}`).join(", ");
5646
+ }
5647
+
5478
5648
  // src/apply/mark.ts
5479
5649
  async function markApplied(options, ctx) {
5480
5650
  if (!options.noCommit && !isStaged(ctx.cwd)) {
@@ -5488,10 +5658,19 @@ async function markApplied(options, ctx) {
5488
5658
  const projectCtx = await loadProjectContext(ctx);
5489
5659
  const email = getUserEmail(ctx.cwd);
5490
5660
  const appliedByLabel = options.tool ? `${email} via ${options.tool}` : email;
5661
+ const models = mergeModels(
5662
+ options.models,
5663
+ options.model ? [normalizeEntry({ model: options.model })] : void 0
5664
+ );
5665
+ const aiModelFields = modelsBody(models);
5666
+ const warning = delegationWarning(projectCtx.delegation ?? "auto", models);
5667
+ if (warning)
5668
+ console.error(warning);
5491
5669
  if (options.dryRun) {
5492
5670
  console.log(`[dry-run] Would mark comment ${options.id} as applied`);
5493
5671
  console.log(`[dry-run] Reply: ${options.reply}`);
5494
5672
  console.log(`[dry-run] AppliedBy: ${appliedByLabel}`);
5673
+ console.log(`[dry-run] Models: ${describeModels(models)}`);
5495
5674
  return {
5496
5675
  committed: false,
5497
5676
  patchedIds: typeof options.id === "number" ? [options.id] : []
@@ -5535,7 +5714,7 @@ async function markApplied(options, ctx) {
5535
5714
  // Structured AI attribution on the reply itself (Reply.AiTool/AiModel), distinct from the
5536
5715
  // free-text appliedByLabel above — see CommentService.Normalize server-side.
5537
5716
  aiTool: options.tool || void 0,
5538
- aiModel: options.model || void 0
5717
+ ...aiModelFields
5539
5718
  },
5540
5719
  token: ctx.token
5541
5720
  });
@@ -5579,7 +5758,7 @@ async function markApplied(options, ctx) {
5579
5758
  // against a deployed build.
5580
5759
  commitSha: sha || null,
5581
5760
  aiTool: options.tool || void 0,
5582
- aiModel: options.model || void 0
5761
+ ...aiModelFields
5583
5762
  },
5584
5763
  token: ctx.token
5585
5764
  });
@@ -5599,9 +5778,10 @@ async function markApplied(options, ctx) {
5599
5778
  }
5600
5779
  async function markFailed(id, reason, ctx, tool, model) {
5601
5780
  const replyBody = `Could not apply: ${reason}`;
5781
+ const models = typeof model === "string" ? model ? [normalizeEntry({ model })] : [] : model ?? [];
5602
5782
  await api(ctx.server, `/api/comments/${id}/replies`, {
5603
5783
  method: "POST",
5604
- body: { body: replyBody, aiTool: tool || void 0, aiModel: model || void 0 },
5784
+ body: { body: replyBody, aiTool: tool || void 0, ...modelsBody(models) },
5605
5785
  token: ctx.token
5606
5786
  });
5607
5787
  await postEvent(ctx.server, ctx.token, {
@@ -5636,6 +5816,20 @@ async function exitIfWorkspaceFrozen(server, token) {
5636
5816
  } catch {
5637
5817
  }
5638
5818
  }
5819
+ function parseModelFlags(parsed) {
5820
+ try {
5821
+ return resolveModels({
5822
+ models: typeof parsed["models"] === "string" ? parsed["models"] : void 0,
5823
+ model: typeof parsed["model"] === "string" ? parsed["model"] : void 0
5824
+ });
5825
+ } catch (err) {
5826
+ if (err instanceof ModelsError) {
5827
+ console.error(err.message);
5828
+ process.exit(2);
5829
+ }
5830
+ throw err;
5831
+ }
5832
+ }
5639
5833
  async function applyCommand(cwd2, parsed, positionals = []) {
5640
5834
  const root = await findRepoRoot(cwd2);
5641
5835
  const config = await readConfig(root);
@@ -5672,6 +5866,12 @@ async function applyCommand(cwd2, parsed, positionals = []) {
5672
5866
  console.error(tooOldMessage(BUILD_CLI_VERSION, minCli));
5673
5867
  process.exit(5);
5674
5868
  }
5869
+ await checkAndNotifyUpdates({
5870
+ meta,
5871
+ config,
5872
+ cwd: root,
5873
+ silent: Boolean(parsed["json"])
5874
+ });
5675
5875
  } catch (err) {
5676
5876
  if (!(err instanceof ApiError && err.code === 404)) {
5677
5877
  }
@@ -5710,8 +5910,10 @@ async function applyCommand(cwd2, parsed, positionals = []) {
5710
5910
  markId = parsedNum;
5711
5911
  }
5712
5912
  const reply = typeof parsed["reply"] === "string" ? parsed["reply"] : "";
5713
- if (!reply) {
5714
- console.error('--reply "<text>" is required when using --mark');
5913
+ if (!reply || reply.trim().length < 15 || /^(done|fixed|applied|ok|yes)$/i.test(reply.trim())) {
5914
+ console.error(
5915
+ '--reply "<text>" is required when using --mark and must provide a substantive explanation of the changes (at least 15 characters, e.g. "Applied \u2713 \u2014 updated logo source in header").'
5916
+ );
5715
5917
  process.exit(2);
5716
5918
  }
5717
5919
  const noCommit = parsed["no-commit"] === true;
@@ -5723,7 +5925,20 @@ async function applyCommand(cwd2, parsed, positionals = []) {
5723
5925
  `\u26A0 --tool not passed \u2014 defaulting to "${config.aiTool}" (recorded when this project was initialized). Pass --tool <your-tool-name> explicitly if you are not that tool.`
5724
5926
  );
5725
5927
  }
5726
- const model = (typeof parsed["model"] === "string" ? parsed["model"] : void 0) || process.env.PINSAY_AI_MODEL;
5928
+ const models = parseModelFlags(parsed);
5929
+ if (models.length === 0) {
5930
+ console.error(
5931
+ '--models "<model>=<role>" is mandatory when marking a comment as applied.\nSpecify your actual active runtime model and role (e.g. --models "<active-model>=implementer").\nIf multiple models or subagents participated (planner, implementer, reviewer), list all of them separated by commas.\nNever guess or hardcode model names from memory.'
5932
+ );
5933
+ process.exit(2);
5934
+ }
5935
+ const missingRole = models.find((m) => !m.role);
5936
+ if (missingRole) {
5937
+ console.error(
5938
+ `Invalid --models entry "${missingRole.model}": explicit role is required. Expected "<model>=<role>" where role is one of: planner, implementer, reviewer (e.g. "${missingRole.model}=implementer").`
5939
+ );
5940
+ process.exit(2);
5941
+ }
5727
5942
  const markStatus = typeof parsed["status"] === "string" ? parsed["status"] : void 0;
5728
5943
  const markEnv = typeof parsed["env"] === "string" ? parsed["env"] : void 0;
5729
5944
  const marked = await markApplied(
@@ -5733,7 +5948,7 @@ async function applyCommand(cwd2, parsed, positionals = []) {
5733
5948
  noCommit,
5734
5949
  dryRun,
5735
5950
  tool: tool2,
5736
- model,
5951
+ models,
5737
5952
  filter: markStatus !== void 0 || markEnv !== void 0 ? { status: markStatus, environment: markEnv } : void 0
5738
5953
  },
5739
5954
  clientCtx
@@ -5763,8 +5978,8 @@ async function applyCommand(cwd2, parsed, positionals = []) {
5763
5978
  `\u26A0 --tool not passed \u2014 defaulting to "${config.aiTool}" (recorded when this project was initialized). Pass --tool <your-tool-name> explicitly if you are not that tool.`
5764
5979
  );
5765
5980
  }
5766
- const failModel = (typeof parsed["model"] === "string" ? parsed["model"] : void 0) || process.env.PINSAY_AI_MODEL;
5767
- await markFailed(failId, reason, clientCtx, failTool, failModel);
5981
+ const failModels = parseModelFlags(parsed);
5982
+ await markFailed(failId, reason, clientCtx, failTool, failModels);
5768
5983
  process.exit(0);
5769
5984
  }
5770
5985
  if (parsed["json"] === true && !parsed["plan"] && !parsed["tool"]) {
@@ -5806,6 +6021,12 @@ async function applyAllProjects(root, cwd2, config, server, parsed) {
5806
6021
  console.error(tooOldMessage(BUILD_CLI_VERSION, minCli));
5807
6022
  process.exit(5);
5808
6023
  }
6024
+ await checkAndNotifyUpdates({
6025
+ meta,
6026
+ config,
6027
+ cwd: root,
6028
+ silent: Boolean(parsed["json"])
6029
+ });
5809
6030
  } catch (err) {
5810
6031
  if (!(err instanceof ApiError && err.code === 404)) {
5811
6032
  }
@@ -5857,7 +6078,7 @@ init_build_constants();
5857
6078
 
5858
6079
  // src/mcp/server.ts
5859
6080
  import { readFileSync as readFileSync3 } from "node:fs";
5860
- import { join as join21 } from "node:path";
6081
+ import { join as join22 } from "node:path";
5861
6082
 
5862
6083
  // node_modules/zod/v3/external.js
5863
6084
  var external_exports = {};
@@ -11242,9 +11463,24 @@ var TOOL_PINSAY_GET_COMMENT = {
11242
11463
  additionalProperties: false
11243
11464
  }
11244
11465
  };
11466
+ var MODELS_NOTICE = "Pass `models` with EVERY model that touched the item and its role (planner/reviewer = your own id, implementer = each worker model you delegated to), using exact model ids, not aliases.";
11467
+ var MODELS_PROPERTY = {
11468
+ type: "array",
11469
+ maxItems: 8,
11470
+ description: 'ALWAYS pass this: EVERY model that worked on the item, each with its role. Include the planner/reviewer (your own model id) and every worker model you delegated to (role "implementer"). Use exact model ids, not aliases like "haiku"/"sonnet". With one model and no delegation, pass just yours as implementer.',
11471
+ items: {
11472
+ type: "object",
11473
+ properties: {
11474
+ model: { type: "string", description: 'Exact model id, e.g. "claude-opus-5-5"' },
11475
+ role: { type: "string", enum: ["planner", "implementer", "reviewer"] }
11476
+ },
11477
+ required: ["model"],
11478
+ additionalProperties: false
11479
+ }
11480
+ };
11245
11481
  var TOOL_PINSAY_MARK_APPLIED = {
11246
11482
  name: "pinsay_mark_applied",
11247
- description: `Mark one comment applied and post the reply on it, recording the optional commitUrl, the git user.email as the applier, and the tool/model attribution. Runs no git command and records no commit sha, so a comment marked this way is never flipped to Live by \`npx pinsay-cli status --deployed\`. Use it when a human will commit the change; to commit now, use pinsay_commit_and_mark. Returns {id, status, commitUrl}. ${UNTRUSTED_NOTICE}`,
11483
+ description: `Mark one comment applied and post the reply on it, recording the optional commitUrl, the git user.email as the applier, and the tool/model attribution. Runs no git command and records no commit sha, so a comment marked this way is never flipped to Live by \`npx pinsay-cli status --deployed\`. Use it when a human will commit the change; to commit now, use pinsay_commit_and_mark. Returns {id, status, commitUrl}. ${MODELS_NOTICE} ${UNTRUSTED_NOTICE}`,
11248
11484
  inputSchema: {
11249
11485
  type: "object",
11250
11486
  properties: {
@@ -11266,8 +11502,9 @@ var TOOL_PINSAY_MARK_APPLIED = {
11266
11502
  },
11267
11503
  model: {
11268
11504
  type: "string",
11269
- description: 'The model id you are running as (e.g. "claude-sonnet-5", "gpt-5.2"). Always pass this too.'
11270
- }
11505
+ description: 'Legacy single model id (role unset); prefer `models`. Exact id, not an alias (e.g. "claude-sonnet-5-5", "gpt-5.2").'
11506
+ },
11507
+ models: MODELS_PROPERTY
11271
11508
  },
11272
11509
  required: ["id", "reply"],
11273
11510
  additionalProperties: false
@@ -11275,7 +11512,7 @@ var TOOL_PINSAY_MARK_APPLIED = {
11275
11512
  };
11276
11513
  var TOOL_PINSAY_COMMIT_AND_MARK = {
11277
11514
  name: "pinsay_commit_and_mark",
11278
- description: `Commit the applied changes with git and mark the comments applied, posting the same reply on each and recording its commit URL. Paths in files are relative to the repository root and must exist inside it; they are staged first. Omit files to commit what is already staged (errors when nothing is). The commit takes everything in the index, not only files. The project's commitStyle decides the commits: "single" makes one commit for all ids; "separate" makes one commit per id, and with more than one id files is required and each entry is prefixed "<id>:" to assign it to that comment (e.g. "12:src/Button.tsx"; unprefixed entries go to the first id). Never pushes. Returns [{id, commitUrl}]. ${UNTRUSTED_NOTICE}`,
11515
+ description: `Commit the applied changes with git and mark the comments applied, posting the same reply on each and recording its commit URL. Paths in files are relative to the repository root and must exist inside it; they are staged first. Omit files to commit what is already staged (errors when nothing is). The commit takes everything in the index, not only files. The project's commitStyle decides the commits: "single" makes one commit for all ids; "separate" makes one commit per id, and with more than one id files is required and each entry is prefixed "<id>:" to assign it to that comment (e.g. "12:src/Button.tsx"; unprefixed entries go to the first id). Never pushes. Returns [{id, commitUrl}]. ${MODELS_NOTICE} ${UNTRUSTED_NOTICE}`,
11279
11516
  inputSchema: {
11280
11517
  type: "object",
11281
11518
  properties: {
@@ -11303,8 +11540,9 @@ var TOOL_PINSAY_COMMIT_AND_MARK = {
11303
11540
  },
11304
11541
  model: {
11305
11542
  type: "string",
11306
- description: 'The model id you are running as (e.g. "claude-sonnet-5", "gpt-5.2"). Always pass this too.'
11307
- }
11543
+ description: 'Legacy single model id (role unset); prefer `models`. Exact id, not an alias (e.g. "claude-sonnet-5-5", "gpt-5.2").'
11544
+ },
11545
+ models: MODELS_PROPERTY
11308
11546
  },
11309
11547
  required: ["ids", "reply"],
11310
11548
  additionalProperties: false
@@ -11312,7 +11550,7 @@ var TOOL_PINSAY_COMMIT_AND_MARK = {
11312
11550
  };
11313
11551
  var TOOL_PINSAY_REPLY = {
11314
11552
  name: "pinsay_reply",
11315
- description: `Post a reply on a comment, visible to its author and the other stakeholders, with tool/model attribution. Does not change the comment's status; to close a comment out with a reply, use pinsay_mark_applied or pinsay_commit_and_mark. Returns {replyId}. ${UNTRUSTED_NOTICE}`,
11553
+ description: `Post a reply on a comment, visible to its author and the other stakeholders, with tool/model attribution. Does not change the comment's status; to close a comment out with a reply, use pinsay_mark_applied or pinsay_commit_and_mark. Returns {replyId}. ${MODELS_NOTICE} ${UNTRUSTED_NOTICE}`,
11316
11554
  inputSchema: {
11317
11555
  type: "object",
11318
11556
  properties: {
@@ -11330,8 +11568,9 @@ var TOOL_PINSAY_REPLY = {
11330
11568
  },
11331
11569
  model: {
11332
11570
  type: "string",
11333
- description: 'The model id you are running as (e.g. "claude-sonnet-5", "gpt-5.2"). Always pass this too.'
11334
- }
11571
+ description: 'Legacy single model id (role unset); prefer `models`. Exact id, not an alias (e.g. "claude-sonnet-5-5", "gpt-5.2").'
11572
+ },
11573
+ models: MODELS_PROPERTY
11335
11574
  },
11336
11575
  required: ["id", "body"],
11337
11576
  additionalProperties: false
@@ -11411,7 +11650,7 @@ var ALL_TOOLS = [
11411
11650
  // src/mcp/tools.ts
11412
11651
  init_api();
11413
11652
  import { existsSync as existsSync4, readFileSync as readFileSync2 } from "node:fs";
11414
- import { isAbsolute as isAbsolute3, join as join20, relative as relative5, resolve as resolve4 } from "node:path";
11653
+ import { isAbsolute as isAbsolute3, join as join21, relative as relative5, resolve as resolve4 } from "node:path";
11415
11654
  import { spawnSync as spawnSync3 } from "node:child_process";
11416
11655
  init_build_constants();
11417
11656
  init_projection();
@@ -11670,6 +11909,15 @@ async function handleGetComment(args, ctx) {
11670
11909
  throw err;
11671
11910
  }
11672
11911
  }
11912
+ function parseModelsArgs(args) {
11913
+ try {
11914
+ return resolveModelsFromArgs(args);
11915
+ } catch (err) {
11916
+ if (err instanceof ModelsError)
11917
+ throw mcpError("forbidden", err.message);
11918
+ throw err;
11919
+ }
11920
+ }
11673
11921
  async function handleMarkApplied(args, ctx) {
11674
11922
  const id = Number(args?.id);
11675
11923
  const reply = args?.reply;
@@ -11680,7 +11928,7 @@ async function handleMarkApplied(args, ctx) {
11680
11928
  const appliedByLabel = email;
11681
11929
  const commitUrl = args.commitUrl || null;
11682
11930
  const tool = args.tool || ctx.configAiTool;
11683
- const model = args.model || process.env.PINSAY_AI_MODEL;
11931
+ const models = parseModelsArgs(args);
11684
11932
  await api(ctx.server, `/api/comments/${id}`, {
11685
11933
  method: "PATCH",
11686
11934
  body: {
@@ -11689,7 +11937,7 @@ async function handleMarkApplied(args, ctx) {
11689
11937
  appliedByLabel,
11690
11938
  commitUrl,
11691
11939
  aiTool: tool || void 0,
11692
- aiModel: model || void 0
11940
+ ...modelsBody(models)
11693
11941
  },
11694
11942
  token: ctx.token
11695
11943
  });
@@ -11709,7 +11957,7 @@ async function handleCommitAndMark(args, ctx) {
11709
11957
  const reply = args?.reply;
11710
11958
  const files = args?.files;
11711
11959
  const tool = args?.tool || ctx.configAiTool;
11712
- const model = args?.model || process.env.PINSAY_AI_MODEL;
11960
+ const models = parseModelsArgs(args);
11713
11961
  if (!Array.isArray(ids) || ids.length === 0 || typeof reply !== "string") {
11714
11962
  throw mcpError("git", "ids (non-empty array) and reply (string) are required");
11715
11963
  }
@@ -11800,7 +12048,7 @@ async function handleCommitAndMark(args, ctx) {
11800
12048
  // so the same fix that shipped for `pinsay apply` has to hold here.
11801
12049
  commitSha: sha,
11802
12050
  aiTool: tool || void 0,
11803
- aiModel: model || void 0
12051
+ ...modelsBody(models)
11804
12052
  },
11805
12053
  token: ctx.token
11806
12054
  });
@@ -11846,7 +12094,7 @@ async function handleCommitAndMark(args, ctx) {
11846
12094
  // Same reason as the single-comment path above.
11847
12095
  commitSha: sha,
11848
12096
  aiTool: tool || void 0,
11849
- aiModel: model || void 0
12097
+ ...modelsBody(models)
11850
12098
  },
11851
12099
  token: ctx.token
11852
12100
  });
@@ -11888,7 +12136,7 @@ async function handleCommitAndMark(args, ctx) {
11888
12136
  // stays "applied" forever even once the fix ships.
11889
12137
  commitSha: sha,
11890
12138
  aiTool: tool || void 0,
11891
- aiModel: model || void 0
12139
+ ...modelsBody(models)
11892
12140
  },
11893
12141
  token: ctx.token
11894
12142
  });
@@ -11909,10 +12157,10 @@ async function handleReply(args, ctx) {
11909
12157
  throw mcpError("forbidden", "id and body are required");
11910
12158
  }
11911
12159
  const tool = args?.tool || ctx.configAiTool;
11912
- const model = args?.model || process.env.PINSAY_AI_MODEL;
12160
+ const models = parseModelsArgs(args);
11913
12161
  const res = await api(ctx.server, `/api/comments/${id}/replies`, {
11914
12162
  method: "POST",
11915
- body: { body, aiTool: tool || void 0, aiModel: model || void 0 },
12163
+ body: { body, aiTool: tool || void 0, ...modelsBody(models) },
11916
12164
  token: ctx.token
11917
12165
  });
11918
12166
  return { replyId: res?.id ?? res?.data?.id ?? null };
@@ -11942,7 +12190,7 @@ async function handleResolveSource(args, ctx) {
11942
12190
  if (!hash || typeof hash !== "string") {
11943
12191
  throw mcpError("forbidden", "hash is required");
11944
12192
  }
11945
- const manifestPath = join20(ctx.cwd, ".pinsay/manifest.json");
12193
+ const manifestPath = join21(ctx.cwd, ".pinsay/manifest.json");
11946
12194
  if (!existsSync4(manifestPath)) {
11947
12195
  return { path: null, reason: "no-manifest" };
11948
12196
  }
@@ -12122,12 +12370,12 @@ function createMcpServer(ctx) {
12122
12370
  let configObj = {};
12123
12371
  let stackObj = {};
12124
12372
  try {
12125
- const configPath = join21(ctx.cwd, ".pinsay/config.json");
12373
+ const configPath = join22(ctx.cwd, ".pinsay/config.json");
12126
12374
  configObj = JSON.parse(readFileSync3(configPath, "utf8"));
12127
12375
  } catch {
12128
12376
  }
12129
12377
  try {
12130
- const stackPath = join21(ctx.cwd, ".pinsay/stack.json");
12378
+ const stackPath = join22(ctx.cwd, ".pinsay/stack.json");
12131
12379
  stackObj = JSON.parse(readFileSync3(stackPath, "utf8"));
12132
12380
  } catch {
12133
12381
  }
@@ -12192,8 +12440,8 @@ function createMcpServer(ctx) {
12192
12440
  async function runMcpServer(ctx, logFile) {
12193
12441
  if (logFile) {
12194
12442
  try {
12195
- const fs20 = await import("node:fs");
12196
- const logStream = fs20.createWriteStream(logFile, { flags: "a" });
12443
+ const fs21 = await import("node:fs");
12444
+ const logStream = fs21.createWriteStream(logFile, { flags: "a" });
12197
12445
  const origWrite = process.stderr.write;
12198
12446
  process.stderr.write = function(chunk, encoding, cb) {
12199
12447
  logStream.write(chunk);
@@ -12616,6 +12864,11 @@ Options:
12616
12864
  --reply <text> Reply text for applied comment (required with --mark)
12617
12865
  --no-commit Skip git commit during --mark (PATCH only)
12618
12866
  --dry-run Print what --mark would do without making git/API changes
12867
+ --models <list> Every model that worked on the item, with its role, for --mark/--fail:
12868
+ "<id>=<role>,<id>=<role>" (role: planner|implementer|reviewer, optional;
12869
+ e.g. "claude-opus-5-5=planner,claude-sonnet-5-5=implementer,claude-opus-5-5=reviewer").
12870
+ Env fallback: PINSAY_AI_MODELS
12871
+ --model <id> One model id (role unset); merged after --models. Env: PINSAY_AI_MODEL
12619
12872
  --fail <id> Mark apply failed with a reply
12620
12873
  --reason <text> Failure reason (required with --fail)
12621
12874
  --status <status> Filter queue by status (open, ready, applied, archived)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinsay-cli",
3
- "version": "0.6.10",
3
+ "version": "0.7.1",
4
4
  "description": "Click-to-comment feedback for your web app: install the widget, then turn pending comments into AI apply prompts.",
5
5
  "type": "module",
6
6
  "license": "MIT",