taskplane 0.24.19 → 0.24.21

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/bin/taskplane.mjs CHANGED
@@ -25,6 +25,7 @@ if (nodeMajor < MIN_NODE_MAJOR) {
25
25
  import fs from "node:fs";
26
26
  import path from "node:path";
27
27
  import readline from "node:readline";
28
+ import { homedir } from "node:os";
28
29
  import { fileURLToPath } from "node:url";
29
30
  import { execSync, execFileSync, spawn } from "node:child_process";
30
31
  import {
@@ -135,6 +136,110 @@ function getVersion(cmd, flag = "--version") {
135
136
  }
136
137
  }
137
138
 
139
+ /**
140
+ * Parse the tabular output from `pi --list-models` into structured model rows.
141
+ *
142
+ * Expected format:
143
+ * provider model context ...
144
+ * anthropic claude-sonnet-4-6 ...
145
+ */
146
+ export function parsePiListModelsOutput(rawOutput) {
147
+ if (typeof rawOutput !== "string" || rawOutput.trim() === "") return [];
148
+
149
+ const rows = rawOutput.split(/\r?\n/);
150
+ const parsed = new Map();
151
+
152
+ for (const row of rows) {
153
+ const trimmed = row.trim();
154
+ if (!trimmed) continue;
155
+ if (/^provider\s+model\b/i.test(trimmed)) continue;
156
+
157
+ const parts = trimmed.split(/\s+/);
158
+ if (parts.length < 2) continue;
159
+
160
+ const provider = parts[0].trim();
161
+ const id = parts[1].trim();
162
+ if (!provider || !id) continue;
163
+ if (!/^[a-z0-9][a-z0-9._-]*$/i.test(provider)) continue;
164
+ if (!/^[^\s]+$/.test(id)) continue;
165
+
166
+ const key = `${provider.toLowerCase()}/${id.toLowerCase()}`;
167
+ if (parsed.has(key)) continue;
168
+
169
+ parsed.set(key, {
170
+ provider,
171
+ id,
172
+ displayName: `${provider}/${id}`,
173
+ });
174
+ }
175
+
176
+ return [...parsed.values()].sort((a, b) =>
177
+ a.provider.localeCompare(b.provider) || a.id.localeCompare(b.id)
178
+ );
179
+ }
180
+
181
+ function extractExecFailure(error) {
182
+ if (!error || typeof error !== "object") return "Unknown error";
183
+ const err = error;
184
+ if (typeof err.stderr === "string" && err.stderr.trim()) return err.stderr.trim();
185
+ if (Buffer.isBuffer(err.stderr)) {
186
+ const msg = err.stderr.toString("utf-8").trim();
187
+ if (msg) return msg;
188
+ }
189
+ if (typeof err.message === "string" && err.message.trim()) return err.message.trim();
190
+ return "Unknown error";
191
+ }
192
+
193
+ /**
194
+ * Query available models from pi in standalone CLI context.
195
+ *
196
+ * Returns a structured model list and diagnostics; never throws.
197
+ */
198
+ export function queryAvailableModelsFromPi({
199
+ execFileSyncImpl = execFileSync,
200
+ commandExistsImpl = commandExists,
201
+ timeoutMs = 10000,
202
+ } = {}) {
203
+ if (!commandExistsImpl("pi")) {
204
+ return {
205
+ models: [],
206
+ source: "pi --list-models",
207
+ available: false,
208
+ error: "pi is not available on PATH",
209
+ };
210
+ }
211
+
212
+ try {
213
+ const output = execFileSyncImpl("pi", ["--list-models"], {
214
+ encoding: "utf-8",
215
+ stdio: ["ignore", "pipe", "pipe"],
216
+ timeout: timeoutMs,
217
+ });
218
+ const models = parsePiListModelsOutput(output);
219
+ if (models.length === 0) {
220
+ return {
221
+ models: [],
222
+ source: "pi --list-models",
223
+ available: false,
224
+ error: "pi returned no parseable model rows",
225
+ };
226
+ }
227
+ return {
228
+ models,
229
+ source: "pi --list-models",
230
+ available: true,
231
+ error: null,
232
+ };
233
+ } catch (error) {
234
+ return {
235
+ models: [],
236
+ source: "pi --list-models",
237
+ available: false,
238
+ error: extractExecFailure(error),
239
+ };
240
+ }
241
+ }
242
+
138
243
  /** Write a file, creating parent directories as needed. Optionally skip if exists. */
139
244
  function writeFile(dest, content, { skipIfExists = false, label = "" } = {}) {
140
245
  if (skipIfExists && fs.existsSync(dest)) {
@@ -277,8 +382,338 @@ function buildTestingCommands(vars) {
277
382
  return commands;
278
383
  }
279
384
 
280
- function generateProjectConfig(vars) {
385
+ const USER_PREFERENCES_SUBDIR = "taskplane";
386
+ const USER_PREFERENCES_FILENAME = "preferences.json";
387
+
388
+ function createInheritInitAgentConfig() {
281
389
  return {
390
+ workerModel: "",
391
+ reviewerModel: "",
392
+ mergeModel: "",
393
+ workerThinking: "",
394
+ reviewerThinking: "",
395
+ mergeThinking: "",
396
+ };
397
+ }
398
+
399
+ function normalizeThinkingMode(value) {
400
+ const cleaned = String(value ?? "").trim().toLowerCase();
401
+ if (cleaned === "on") return "on";
402
+ if (cleaned === "off") return "off";
403
+ return "";
404
+ }
405
+
406
+ function normalizeModelValue(value) {
407
+ const trimmed = String(value ?? "").trim();
408
+ return trimmed.toLowerCase() === "inherit" ? "" : trimmed;
409
+ }
410
+
411
+ function sanitizeInitAgentConfig(raw) {
412
+ const defaults = createInheritInitAgentConfig();
413
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return defaults;
414
+
415
+ if (typeof raw.workerModel === "string") defaults.workerModel = normalizeModelValue(raw.workerModel);
416
+ if (typeof raw.reviewerModel === "string") defaults.reviewerModel = normalizeModelValue(raw.reviewerModel);
417
+ if (typeof raw.mergeModel === "string") defaults.mergeModel = normalizeModelValue(raw.mergeModel);
418
+ if (raw.workerThinking !== undefined) defaults.workerThinking = normalizeThinkingMode(raw.workerThinking);
419
+ if (raw.reviewerThinking !== undefined) defaults.reviewerThinking = normalizeThinkingMode(raw.reviewerThinking);
420
+ if (raw.mergeThinking !== undefined) defaults.mergeThinking = normalizeThinkingMode(raw.mergeThinking);
421
+
422
+ return defaults;
423
+ }
424
+
425
+ function resolveUserPreferencesPathForCli() {
426
+ const agentDir = process.env.PI_CODING_AGENT_DIR;
427
+ if (agentDir) {
428
+ return path.join(agentDir, USER_PREFERENCES_SUBDIR, USER_PREFERENCES_FILENAME);
429
+ }
430
+ return path.join(homedir(), ".pi", "agent", USER_PREFERENCES_SUBDIR, USER_PREFERENCES_FILENAME);
431
+ }
432
+
433
+ function readUserPreferencesForCli() {
434
+ const prefsPath = resolveUserPreferencesPathForCli();
435
+ if (!fs.existsSync(prefsPath)) {
436
+ return {
437
+ prefsPath,
438
+ raw: {},
439
+ };
440
+ }
441
+
442
+ try {
443
+ const raw = JSON.parse(fs.readFileSync(prefsPath, "utf-8"));
444
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
445
+ return { prefsPath, raw: {} };
446
+ }
447
+ return { prefsPath, raw };
448
+ } catch {
449
+ return { prefsPath, raw: {} };
450
+ }
451
+ }
452
+
453
+ function writeUserPreferencesForCli(rawPrefs) {
454
+ const prefsPath = resolveUserPreferencesPathForCli();
455
+ fs.mkdirSync(path.dirname(prefsPath), { recursive: true });
456
+ fs.writeFileSync(prefsPath, JSON.stringify(rawPrefs, null, 2) + "\n", "utf-8");
457
+ return prefsPath;
458
+ }
459
+
460
+ function loadInitAgentDefaultsFromPreferences() {
461
+ const { prefsPath, raw } = readUserPreferencesForCli();
462
+ const defaults = sanitizeInitAgentConfig(raw.initAgentDefaults);
463
+ const hasDefaults = !!(raw.initAgentDefaults && typeof raw.initAgentDefaults === "object" && !Array.isArray(raw.initAgentDefaults));
464
+ return { defaults, hasDefaults, prefsPath };
465
+ }
466
+
467
+ function saveInitAgentDefaultsToPreferences(initAgentConfig) {
468
+ const { raw } = readUserPreferencesForCli();
469
+ raw.initAgentDefaults = sanitizeInitAgentConfig(initAgentConfig);
470
+ const prefsPath = writeUserPreferencesForCli(raw);
471
+ return { prefsPath, saved: raw.initAgentDefaults };
472
+ }
473
+
474
+ function splitModelReference(modelRef) {
475
+ const trimmed = String(modelRef ?? "").trim();
476
+ if (!trimmed.includes("/")) return null;
477
+ const [provider, ...idParts] = trimmed.split("/");
478
+ const id = idParts.join("/").trim();
479
+ if (!provider || !id) return null;
480
+ return { provider, id };
481
+ }
482
+
483
+ function allValuesEqual(values) {
484
+ if (!Array.isArray(values) || values.length === 0) return true;
485
+ const first = values[0];
486
+ return values.every((value) => value === first);
487
+ }
488
+
489
+ const INIT_AGENT_ROLES = [
490
+ { key: "worker", label: "Worker", modelKey: "workerModel", thinkingKey: "workerThinking" },
491
+ { key: "reviewer", label: "Reviewer", modelKey: "reviewerModel", thinkingKey: "reviewerThinking" },
492
+ { key: "merge", label: "Merger", modelKey: "mergeModel", thinkingKey: "mergeThinking" },
493
+ ];
494
+
495
+ async function promptMenuChoice({ title, question, options, defaultIndex = 0, askImpl = ask, logImpl = console.log }) {
496
+ while (true) {
497
+ if (title) logImpl(`\n ${title}`);
498
+ for (let i = 0; i < options.length; i++) {
499
+ logImpl(` ${i + 1}. ${options[i].label}`);
500
+ }
501
+
502
+ const resolvedDefault = Number.isInteger(defaultIndex) && defaultIndex >= 0 && defaultIndex < options.length
503
+ ? defaultIndex
504
+ : 0;
505
+ const answer = String(await askImpl(question, String(resolvedDefault + 1))).trim();
506
+ const asNum = Number.parseInt(answer, 10);
507
+ if (!Number.isNaN(asNum) && asNum >= 1 && asNum <= options.length) {
508
+ return options[asNum - 1].value;
509
+ }
510
+
511
+ const lower = answer.toLowerCase();
512
+ const byAlias = options.find((option) => {
513
+ const aliases = [option.value, ...(option.aliases || [])]
514
+ .filter(Boolean)
515
+ .map((entry) => String(entry).toLowerCase());
516
+ return aliases.includes(lower);
517
+ });
518
+ if (byAlias) return byAlias.value;
519
+
520
+ logImpl(` ${WARN} Invalid selection. Enter a menu number.`);
521
+ }
522
+ }
523
+
524
+ async function promptModelForRole(roleLabel, models, {
525
+ askImpl = ask,
526
+ logImpl = console.log,
527
+ currentModel = "",
528
+ } = {}) {
529
+ const providers = [...new Set(models.map((model) => model.provider))].sort((a, b) => a.localeCompare(b));
530
+ const currentRef = splitModelReference(currentModel);
531
+
532
+ while (true) {
533
+ const providerOptions = [
534
+ { value: "inherit", label: "inherit (use current session model)", aliases: ["inherit"] },
535
+ ...providers.map((provider) => {
536
+ const count = models.filter((model) => model.provider === provider).length;
537
+ return {
538
+ value: provider,
539
+ label: `${provider} (${count} models)`,
540
+ aliases: [provider],
541
+ };
542
+ }),
543
+ ];
544
+ const providerDefaultIndex = currentRef
545
+ ? Math.max(0, providerOptions.findIndex((option) => option.value === currentRef.provider))
546
+ : 0;
547
+
548
+ const providerChoice = await promptMenuChoice({
549
+ title: `${roleLabel}: choose model provider`,
550
+ question: `${roleLabel} provider (number or provider name)`,
551
+ options: providerOptions,
552
+ defaultIndex: providerDefaultIndex,
553
+ askImpl,
554
+ logImpl,
555
+ });
556
+
557
+ if (providerChoice === "inherit") return "";
558
+
559
+ const providerModels = models
560
+ .filter((model) => model.provider === providerChoice)
561
+ .sort((a, b) => a.id.localeCompare(b.id));
562
+
563
+ const modelOptions = [
564
+ { value: "back", label: "← back to providers", aliases: ["back"] },
565
+ ...providerModels.map((model) => ({
566
+ value: model.id,
567
+ label: model.id,
568
+ aliases: [model.id, `${model.provider}/${model.id}`],
569
+ })),
570
+ ];
571
+ const modelDefaultIndex = (() => {
572
+ if (currentRef?.provider === providerChoice) {
573
+ const idx = providerModels.findIndex((model) => model.id === currentRef.id);
574
+ if (idx >= 0) return idx + 1;
575
+ }
576
+ return providerModels.length > 0 ? 1 : 0;
577
+ })();
578
+
579
+ const modelChoice = await promptMenuChoice({
580
+ title: `${roleLabel}: choose model (${providerChoice})`,
581
+ question: `${roleLabel} model (number or model id)`,
582
+ options: modelOptions,
583
+ defaultIndex: modelDefaultIndex,
584
+ askImpl,
585
+ logImpl,
586
+ });
587
+
588
+ if (modelChoice === "back") continue;
589
+ return `${providerChoice}/${modelChoice}`;
590
+ }
591
+ }
592
+
593
+ async function promptThinkingForRole(roleLabel, {
594
+ askImpl = ask,
595
+ logImpl = console.log,
596
+ currentThinking = "",
597
+ } = {}) {
598
+ const thinkingOptions = [
599
+ { value: "", label: "inherit (use current session thinking)", aliases: ["inherit"] },
600
+ { value: "on", label: "on" },
601
+ { value: "off", label: "off" },
602
+ ];
603
+ const normalized = normalizeThinkingMode(currentThinking);
604
+ const defaultIndex = Math.max(0, thinkingOptions.findIndex((option) => option.value === normalized));
605
+
606
+ return promptMenuChoice({
607
+ title: `${roleLabel}: choose thinking mode`,
608
+ question: `${roleLabel} thinking (number or value)`,
609
+ options: thinkingOptions,
610
+ defaultIndex,
611
+ askImpl,
612
+ logImpl,
613
+ });
614
+ }
615
+
616
+ function applyInitAgentConfig(projectConfig, initAgentConfig) {
617
+ if (!initAgentConfig) return projectConfig;
618
+
619
+ projectConfig.taskRunner.worker.model = initAgentConfig.workerModel ?? "";
620
+ projectConfig.taskRunner.reviewer.model = initAgentConfig.reviewerModel ?? "";
621
+ projectConfig.orchestrator.merge.model = initAgentConfig.mergeModel ?? "";
622
+
623
+ projectConfig.taskRunner.worker.thinking = normalizeThinkingMode(initAgentConfig.workerThinking);
624
+ projectConfig.taskRunner.reviewer.thinking = normalizeThinkingMode(initAgentConfig.reviewerThinking);
625
+ projectConfig.orchestrator.merge.thinking = normalizeThinkingMode(initAgentConfig.mergeThinking);
626
+
627
+ return projectConfig;
628
+ }
629
+
630
+ export async function collectInitAgentConfig({
631
+ interactive = true,
632
+ askImpl = ask,
633
+ confirmImpl = confirm,
634
+ queryModelsImpl = queryAvailableModelsFromPi,
635
+ loadInitDefaultsImpl = loadInitAgentDefaultsFromPreferences,
636
+ logImpl = console.log,
637
+ } = {}) {
638
+ if (!interactive) return null;
639
+
640
+ const savedDefaults = await loadInitDefaultsImpl();
641
+ const initAgentConfig = sanitizeInitAgentConfig(savedDefaults?.defaults);
642
+ const hasSavedDefaults = !!savedDefaults?.hasDefaults;
643
+
644
+ if (hasSavedDefaults) {
645
+ logImpl(`\n ${INFO} Loaded saved init defaults from ${savedDefaults.prefsPath}`);
646
+ }
647
+
648
+ let discovery;
649
+ try {
650
+ discovery = await queryModelsImpl();
651
+ } catch (error) {
652
+ discovery = {
653
+ models: [],
654
+ available: false,
655
+ error: error?.message || "unknown error",
656
+ };
657
+ }
658
+
659
+ if (!discovery?.available || !Array.isArray(discovery.models) || discovery.models.length === 0) {
660
+ logImpl(`\n ${WARN} Model list unavailable (${discovery?.error || "unknown"}).`);
661
+ if (hasSavedDefaults) {
662
+ logImpl(" Using saved defaults from preferences for worker/reviewer/merger.\n");
663
+ } else {
664
+ logImpl(" Skipping model picker and using inherit defaults for worker/reviewer/merger.\n");
665
+ }
666
+ return initAgentConfig;
667
+ }
668
+
669
+ logImpl(`\n${c.bold}Agent model setup${c.reset}`);
670
+ logImpl(` ${c.dim}Choose models for worker/reviewer/merger (inherit is always option #1).${c.reset}`);
671
+
672
+ const modelDefaults = INIT_AGENT_ROLES.map((role) => initAgentConfig[role.modelKey] || "");
673
+ const thinkingDefaults = INIT_AGENT_ROLES.map((role) => normalizeThinkingMode(initAgentConfig[role.thinkingKey]));
674
+ const sameModelDefaults = allValuesEqual(modelDefaults);
675
+ const sameThinkingDefaults = allValuesEqual(thinkingDefaults);
676
+ const useSameModel = await confirmImpl(
677
+ "Use the same model for worker, reviewer, and merger?",
678
+ sameModelDefaults && sameThinkingDefaults,
679
+ );
680
+
681
+ if (useSameModel) {
682
+ const selectedModel = await promptModelForRole("All agents", discovery.models, {
683
+ askImpl,
684
+ logImpl,
685
+ currentModel: sameModelDefaults ? modelDefaults[0] : "",
686
+ });
687
+ const selectedThinking = await promptThinkingForRole("All agents", {
688
+ askImpl,
689
+ logImpl,
690
+ currentThinking: sameThinkingDefaults ? thinkingDefaults[0] : "",
691
+ });
692
+ for (const role of INIT_AGENT_ROLES) {
693
+ initAgentConfig[role.modelKey] = selectedModel;
694
+ initAgentConfig[role.thinkingKey] = selectedThinking;
695
+ }
696
+ return initAgentConfig;
697
+ }
698
+
699
+ for (const role of INIT_AGENT_ROLES) {
700
+ initAgentConfig[role.modelKey] = await promptModelForRole(role.label, discovery.models, {
701
+ askImpl,
702
+ logImpl,
703
+ currentModel: initAgentConfig[role.modelKey],
704
+ });
705
+ initAgentConfig[role.thinkingKey] = await promptThinkingForRole(role.label, {
706
+ askImpl,
707
+ logImpl,
708
+ currentThinking: initAgentConfig[role.thinkingKey],
709
+ });
710
+ }
711
+
712
+ return initAgentConfig;
713
+ }
714
+
715
+ export function generateProjectConfig(vars, initAgentConfig = null) {
716
+ const projectConfig = {
282
717
  configVersion: 1,
283
718
  taskRunner: {
284
719
  project: { name: vars.project_name, description: "" },
@@ -286,8 +721,8 @@ function generateProjectConfig(vars) {
286
721
  testing: { commands: buildTestingCommands(vars) },
287
722
  standards: { docs: [], rules: [] },
288
723
  standardsOverrides: {},
289
- worker: { model: "", tools: "read,write,edit,bash,grep,find,ls", thinking: "off" },
290
- reviewer: { model: "openai/gpt-5.3-codex", tools: "read,bash,grep,find,ls", thinking: "on" },
724
+ worker: { model: "", tools: "read,write,edit,bash,grep,find,ls", thinking: "" },
725
+ reviewer: { model: "", tools: "read,bash,grep,find,ls", thinking: "on" },
291
726
  context: {
292
727
  workerContextWindow: 200000,
293
728
  warnPercent: 70,
@@ -323,6 +758,7 @@ function generateProjectConfig(vars) {
323
758
  preWarm: { autoDetect: false, commands: {}, always: [] },
324
759
  merge: {
325
760
  model: "",
761
+ thinking: "",
326
762
  tools: "read,write,edit,bash,grep,find,ls",
327
763
  verify: [],
328
764
  order: "fewest-files-first",
@@ -338,6 +774,8 @@ function generateProjectConfig(vars) {
338
774
  monitoring: { pollInterval: 5 },
339
775
  },
340
776
  };
777
+
778
+ return applyInitAgentConfig(projectConfig, initAgentConfig);
341
779
  }
342
780
 
343
781
  function generateWorkspaceYaml(repoNames, defaultRepo, tasksRoot) {
@@ -483,6 +921,71 @@ function listExampleTaskTemplates() {
483
921
  }
484
922
  }
485
923
 
924
+ function inferTaskplaneInstallScope() {
925
+ return /[\\/]\.pi[\\/]/.test(PACKAGE_ROOT) ? "local" : "global";
926
+ }
927
+
928
+ function resolveProjectConfigJsonPath(projectRoot) {
929
+ const pointerPath = path.join(projectRoot, ".pi", "taskplane-pointer.json");
930
+ if (fs.existsSync(pointerPath)) {
931
+ try {
932
+ const pointer = JSON.parse(fs.readFileSync(pointerPath, "utf-8"));
933
+ if (pointer?.config_repo && pointer?.config_path) {
934
+ const pointedPath = path.resolve(projectRoot, pointer.config_repo, pointer.config_path, "taskplane-config.json");
935
+ if (fs.existsSync(pointedPath)) return pointedPath;
936
+ }
937
+ } catch {
938
+ // fall through to local .pi path
939
+ }
940
+ }
941
+
942
+ return path.join(projectRoot, ".pi", "taskplane-config.json");
943
+ }
944
+
945
+ function extractInitAgentConfigFromProjectConfig(projectConfig) {
946
+ return sanitizeInitAgentConfig({
947
+ workerModel: projectConfig?.taskRunner?.worker?.model,
948
+ reviewerModel: projectConfig?.taskRunner?.reviewer?.model,
949
+ mergeModel: projectConfig?.orchestrator?.merge?.model,
950
+ workerThinking: projectConfig?.taskRunner?.worker?.thinking,
951
+ reviewerThinking: projectConfig?.taskRunner?.reviewer?.thinking,
952
+ mergeThinking: projectConfig?.orchestrator?.merge?.thinking,
953
+ });
954
+ }
955
+
956
+ function cmdConfig(args) {
957
+ if (!args.includes("--save-as-defaults")) {
958
+ console.log(`\n${c.bold}Taskplane Config${c.reset}\n`);
959
+ console.log(` ${c.cyan}taskplane config --save-as-defaults${c.reset}`);
960
+ console.log(` Save worker/reviewer/merger model + thinking settings from this project`);
961
+ console.log(` to ${c.cyan}${resolveUserPreferencesPathForCli()}${c.reset} for future ${c.cyan}taskplane init${c.reset} runs.\n`);
962
+ return;
963
+ }
964
+
965
+ const projectRoot = process.cwd();
966
+ const configPath = resolveProjectConfigJsonPath(projectRoot);
967
+ if (!fs.existsSync(configPath)) {
968
+ die(`Project config not found at ${configPath}. Run ${c.cyan}taskplane init${c.reset} first.`);
969
+ }
970
+
971
+ let projectConfig;
972
+ try {
973
+ projectConfig = JSON.parse(fs.readFileSync(configPath, "utf-8"));
974
+ } catch (error) {
975
+ die(`Could not read ${configPath}: ${error?.message || error}`);
976
+ }
977
+
978
+ const defaults = extractInitAgentConfigFromProjectConfig(projectConfig);
979
+ const { prefsPath, saved } = saveInitAgentDefaultsToPreferences(defaults);
980
+
981
+ console.log(`\n${OK} ${c.bold}Saved init defaults.${c.reset}`);
982
+ console.log(` Source: ${c.cyan}${configPath}${c.reset}`);
983
+ console.log(` Target: ${c.cyan}${prefsPath}${c.reset}`);
984
+ console.log(` worker: ${saved.workerModel || "inherit"} (${saved.workerThinking || "inherit"})`);
985
+ console.log(` reviewer: ${saved.reviewerModel || "inherit"} (${saved.reviewerThinking || "inherit"})`);
986
+ console.log(` merger: ${saved.mergeModel || "inherit"} (${saved.mergeThinking || "inherit"})\n`);
987
+ }
988
+
486
989
  async function cmdUninstall(args) {
487
990
  const projectRoot = process.cwd();
488
991
  const dryRun = args.includes("--dry-run");
@@ -540,7 +1043,7 @@ async function cmdUninstall(args) {
540
1043
  .filter(({ abs }) => abs.startsWith(rootPrefix) && fs.existsSync(abs));
541
1044
  }
542
1045
 
543
- const inferredInstallType = /[\\/]\.pi[\\/]/.test(PACKAGE_ROOT) ? "local" : "global";
1046
+ const inferredInstallType = inferTaskplaneInstallScope();
544
1047
  const packageScope = local ? "local" : global ? "global" : inferredInstallType;
545
1048
  const piRemoveCmd = packageScope === "local"
546
1049
  ? "pi remove -l npm:taskplane"
@@ -1306,6 +1809,10 @@ async function cmdInit(args) {
1306
1809
  return;
1307
1810
  }
1308
1811
 
1812
+ const initAgentConfig = await collectInitAgentConfig({
1813
+ interactive: !isPreset,
1814
+ });
1815
+
1309
1816
  // ── Scaffold .taskplane/ in config repo ─────────────────────
1310
1817
  console.log(`\n${c.bold}Creating files in ${configRepoName}/.taskplane/...${c.reset}\n`);
1311
1818
  // Skip existing files only when --force was NOT used AND the user did NOT confirm overwrite
@@ -1337,7 +1844,7 @@ async function cmdInit(args) {
1337
1844
  }
1338
1845
 
1339
1846
  // Project config JSON (taskplane-config.json)
1340
- const projectConfig = generateProjectConfig(vars);
1847
+ const projectConfig = generateProjectConfig(vars, initAgentConfig);
1341
1848
  writeFile(
1342
1849
  path.join(taskplaneDir, "taskplane-config.json"),
1343
1850
  JSON.stringify(projectConfig, null, 2) + "\n",
@@ -1473,6 +1980,9 @@ async function cmdInit(args) {
1473
1980
  console.log(` ${c.cyan}/orch${c.reset} # start the taskplane supervisor`);
1474
1981
  console.log(` ${c.cyan}/orch all${c.reset} # run all open tasks`);
1475
1982
  }
1983
+ if (inferTaskplaneInstallScope() === "global") {
1984
+ console.log(` ${c.cyan}taskplane config --save-as-defaults${c.reset} # save these agent defaults for future inits`);
1985
+ }
1476
1986
  console.log();
1477
1987
  return;
1478
1988
  }
@@ -1518,6 +2028,10 @@ async function cmdInit(args) {
1518
2028
  return;
1519
2029
  }
1520
2030
 
2031
+ const initAgentConfig = await collectInitAgentConfig({
2032
+ interactive: !isPreset,
2033
+ });
2034
+
1521
2035
  // Scaffold files
1522
2036
  console.log(`\n${c.bold}Creating files...${c.reset}\n`);
1523
2037
  // Skip existing files only when --force was NOT used AND the user did NOT confirm overwrite
@@ -1552,7 +2066,7 @@ async function cmdInit(args) {
1552
2066
  // Unified project config JSON
1553
2067
  writeFile(
1554
2068
  path.join(projectRoot, ".pi", "taskplane-config.json"),
1555
- JSON.stringify(generateProjectConfig(vars), null, 2) + "\n",
2069
+ JSON.stringify(generateProjectConfig(vars, initAgentConfig), null, 2) + "\n",
1556
2070
  { skipIfExists, label: ".pi/taskplane-config.json" },
1557
2071
  );
1558
2072
 
@@ -1628,6 +2142,9 @@ async function cmdInit(args) {
1628
2142
  console.log(` ${c.cyan}/orch${c.reset} # start the taskplane supervisor`);
1629
2143
  console.log(` ${c.cyan}/orch all${c.reset} # run all open tasks`);
1630
2144
  }
2145
+ if (inferTaskplaneInstallScope() === "global") {
2146
+ console.log(` ${c.cyan}taskplane config --save-as-defaults${c.reset} # save these agent defaults for future inits`);
2147
+ }
1631
2148
  console.log();
1632
2149
  }
1633
2150
 
@@ -2573,6 +3090,7 @@ ${c.bold}Usage:${c.reset}
2573
3090
  ${c.bold}Commands:${c.reset}
2574
3091
  ${c.cyan}init${c.reset} Scaffold Taskplane config in the current project
2575
3092
  ${c.cyan}doctor${c.reset} Validate installation and project configuration
3093
+ ${c.cyan}config${c.reset} Manage CLI config utilities (e.g., save init defaults)
2576
3094
  ${c.cyan}version${c.reset} Show version information
2577
3095
  ${c.cyan}dashboard${c.reset} Launch the web-based orchestrator dashboard
2578
3096
  ${c.cyan}uninstall${c.reset} Remove Taskplane project files and/or package install
@@ -2590,6 +3108,10 @@ ${c.bold}Dashboard options:${c.reset}
2590
3108
  --port <number> Port to listen on (default: 8099)
2591
3109
  --no-open Don't auto-open browser
2592
3110
 
3111
+ ${c.bold}Config options:${c.reset}
3112
+ --save-as-defaults Save current project's worker/reviewer/merger model + thinking
3113
+ settings to user preferences for future taskplane init runs
3114
+
2593
3115
  ${c.bold}Uninstall options:${c.reset}
2594
3116
  --dry-run Show what would be removed
2595
3117
  --yes, -y Skip confirmation prompts
@@ -2607,6 +3129,7 @@ ${c.bold}Examples:${c.reset}
2607
3129
  # Use existing task area path
2608
3130
  taskplane init --dry-run # Preview what would be created
2609
3131
  taskplane doctor # Check installation health
3132
+ taskplane config --save-as-defaults # Save current agent settings as init defaults
2610
3133
  taskplane dashboard # Launch web dashboard
2611
3134
  taskplane dashboard --port 3000 # Dashboard on custom port
2612
3135
  taskplane uninstall --dry-run # Preview uninstall actions
@@ -2623,34 +3146,56 @@ ${c.bold}Getting started:${c.reset}
2623
3146
  // MAIN
2624
3147
  // ═════════════════════════════════════════════════════════════════════════════
2625
3148
 
2626
- const [command, ...args] = process.argv.slice(2);
2627
-
2628
- switch (command) {
2629
- case "init":
2630
- await cmdInit(args);
2631
- break;
2632
- case "doctor":
2633
- cmdDoctor();
2634
- break;
2635
- case "version":
2636
- case "--version":
2637
- case "-v":
2638
- cmdVersion();
2639
- break;
2640
- case "dashboard":
2641
- cmdDashboard(args);
2642
- break;
2643
- case "uninstall":
2644
- await cmdUninstall(args);
2645
- break;
2646
- case "help":
2647
- case "--help":
2648
- case "-h":
2649
- case undefined:
2650
- showHelp();
2651
- break;
2652
- default:
2653
- console.error(`${FAIL} Unknown command: ${command}`);
2654
- showHelp();
2655
- process.exit(1);
3149
+ export async function main(argv = process.argv.slice(2)) {
3150
+ const [command, ...args] = argv;
3151
+
3152
+ switch (command) {
3153
+ case "init":
3154
+ await cmdInit(args);
3155
+ break;
3156
+ case "doctor":
3157
+ cmdDoctor();
3158
+ break;
3159
+ case "config":
3160
+ cmdConfig(args);
3161
+ break;
3162
+ case "version":
3163
+ case "--version":
3164
+ case "-v":
3165
+ cmdVersion();
3166
+ break;
3167
+ case "dashboard":
3168
+ cmdDashboard(args);
3169
+ break;
3170
+ case "uninstall":
3171
+ await cmdUninstall(args);
3172
+ break;
3173
+ case "help":
3174
+ case "--help":
3175
+ case "-h":
3176
+ case undefined:
3177
+ showHelp();
3178
+ break;
3179
+ default:
3180
+ console.error(`${FAIL} Unknown command: ${command}`);
3181
+ showHelp();
3182
+ process.exit(1);
3183
+ }
3184
+ }
3185
+
3186
+ const isDirectExecution = (() => {
3187
+ const argv1 = process.argv[1];
3188
+ if (!argv1) return false;
3189
+
3190
+ try {
3191
+ const invokedPath = fs.realpathSync(argv1);
3192
+ const modulePath = fs.realpathSync(__filename);
3193
+ return invokedPath === modulePath;
3194
+ } catch {
3195
+ return path.resolve(argv1) === __filename;
3196
+ }
3197
+ })();
3198
+
3199
+ if (isDirectExecution) {
3200
+ await main();
2656
3201
  }