@agentworkforce/cli 0.16.0 → 0.17.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.17.1] - 2026-05-08
11
+
12
+ ### Changed
13
+
14
+ - Address review feedback on CLI launch spinner PR
15
+ - Show spinners during persona session install and exit sync
16
+
17
+ ## [0.17.0] - 2026-05-08
18
+
19
+ ### Added
20
+
21
+ - **Add optional defaultTier to PersonaSpec**
22
+
23
+ ### Changed
24
+
25
+ - Agent: consult routingProfiles before defaultTier on no-@<tier>
26
+
10
27
  ## [0.16.0] - 2026-05-08
11
28
 
12
29
  ### Added
package/dist/cli.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"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"}
package/dist/cli.js CHANGED
@@ -7,7 +7,7 @@ import { dirname, isAbsolute, join, resolve as resolvePath } from 'node:path';
7
7
  import { pathToFileURL } from 'node:url';
8
8
  import { HARNESS_VALUES, materializeSkills, PERSONA_TAGS, PERSONA_TIERS, listBuiltInPersonas, personaCatalog, resolveSidecar, routingProfiles, useSelection } from '@agentworkforce/workload-router';
9
9
  import { buildInteractiveSpec, detectHarnesses, formatDropWarnings, MissingPersonaInputError, renderPersonaInputs, resolvePersonaInputs, resolveMcpServersLenient, resolveStringMapLenient } from '@agentworkforce/harness-kit';
10
- import { launchOnMount, readAgentDotfiles } from '@relayfile/local-mount';
10
+ import { createMount, readAgentDotfiles } from '@relayfile/local-mount';
11
11
  import ora from 'ora';
12
12
  import { startLaunchMetadataRecording } from './launch-metadata.js';
13
13
  import { buildPersonaSourceDirectories, defaultCwdPersonaDir, loadLocalPersonas, loadPersonaSourceConfig, normalizePersonaDir, savePersonaSourceConfig } from './local-personas.js';
@@ -34,9 +34,11 @@ Commands:
34
34
  --no-launch-metadata
35
35
  Same behavior as agent.
36
36
  agent [flags] <persona>[@<tier>]
37
- Run a persona. Tier one of: ${PERSONA_TIERS.join(' | ')}
38
- (default: best-value). Drops into an interactive harness
39
- session.
37
+ Run a persona. Tier one of: ${PERSONA_TIERS.join(' | ')}.
38
+ With no @<tier>, the resolution order is:
39
+ routingProfiles.default.intents (built-in personas only)
40
+ → persona.defaultTier (when set) → best-value. Drops into
41
+ an interactive harness session.
40
42
 
41
43
  Flags:
42
44
  --install-in-repo Disengage the sandbox mount and
@@ -232,14 +234,22 @@ function parseSelector(sel) {
232
234
  const tierRaw = at === -1 ? undefined : sel.slice(at + 1);
233
235
  if (!key)
234
236
  die('Missing persona name before "@"');
235
- const tier = (tierRaw ?? 'best-value');
236
- if (tierRaw !== undefined && !PERSONA_TIERS.includes(tier)) {
237
+ if (tierRaw !== undefined && !PERSONA_TIERS.includes(tierRaw)) {
237
238
  die(`Invalid tier "${tierRaw}". Must be one of: ${PERSONA_TIERS.join(', ')}`);
238
239
  }
239
240
  const result = resolveSpec(key);
240
241
  if ('error' in result)
241
242
  die(result.error, false);
242
243
  const kind = local.byId.has(key) ? 'local' : 'repo';
244
+ // Resolution order when no @<tier> is given: routingProfiles default for the
245
+ // persona's intent (built-ins only — local personas with custom intents miss
246
+ // the lookup and fall through), then the persona's own defaultTier, then
247
+ // 'best-value'. Mirrors `resolveShowTarget` and the `list` recommended-tier
248
+ // filter so all three commands agree on what "no tier" means.
249
+ const profileRule = kind === 'repo'
250
+ ? routingProfiles.default.intents[result.intent]
251
+ : undefined;
252
+ const tier = (tierRaw ?? profileRule?.tier ?? result.defaultTier ?? 'best-value');
243
253
  if (kind === 'local') {
244
254
  return { kind, source: local.sources.get(result.id) ?? 'cwd', spec: result, tier };
245
255
  }
@@ -316,17 +326,53 @@ function subprocessExitCode(res) {
316
326
  return signalExitCode(res.signal);
317
327
  return 1;
318
328
  }
319
- function runInstall(command, label, cwd) {
329
+ /**
330
+ * Run a skill-install subprocess behind an ora spinner. stdout and stderr are
331
+ * captured so a successful install collapses to a single ✓ line; on failure,
332
+ * the buffered output is dumped after spinner.fail so the user sees what
333
+ * actually broke. stdin is ignored — the install commands don't prompt.
334
+ *
335
+ * The spinner text stays "Installing skills…" while running; the longer
336
+ * `label` (which includes target paths and skill ids) is shown on
337
+ * success/failure so the verbose detail is still discoverable in logs.
338
+ */
339
+ function runInstallWithSpinner(command, label, cwd) {
320
340
  const [bin, ...args] = command;
321
341
  if (!bin)
322
- return;
323
- process.stderr.write(`• ${label}\n`);
324
- const res = spawnSync(bin, args, { stdio: 'inherit', shell: false, ...(cwd ? { cwd } : {}) });
342
+ return { code: 0, output: '' };
343
+ const spinner = ora({ text: 'Installing skills…', stream: process.stderr }).start();
344
+ const res = spawnSync(bin, args, {
345
+ stdio: ['ignore', 'pipe', 'pipe'],
346
+ shell: false,
347
+ encoding: 'utf8',
348
+ // Default is 1 MiB; verbose `npx prpm install` / `npx skills add` runs
349
+ // can blow past it, which would have spawnSync kill the child with
350
+ // ENOBUFS and report a spurious failure. 100 MiB is well past anything
351
+ // these installers print in practice.
352
+ maxBuffer: 100 * 1024 * 1024,
353
+ ...(cwd ? { cwd } : {})
354
+ });
355
+ const output = `${res.stdout ?? ''}${res.stderr ?? ''}`;
325
356
  const code = subprocessExitCode(res);
326
- if (code !== 0) {
327
- process.stderr.write(`${label} failed (exit ${code}). Aborting.\n`);
328
- process.exit(code);
357
+ if (code === 0) {
358
+ spinner.succeed(label);
329
359
  }
360
+ else {
361
+ spinner.fail(`${label} failed (exit ${code})`);
362
+ if (output.trim())
363
+ process.stderr.write(output.endsWith('\n') ? output : `${output}\n`);
364
+ }
365
+ return { code, output };
366
+ }
367
+ function runInstall(command, label, cwd) {
368
+ const [bin] = command;
369
+ if (!bin)
370
+ return;
371
+ // runInstallWithSpinner already prints the failure line via spinner.fail;
372
+ // the previous extra "${label} failed … Aborting." write would duplicate it.
373
+ const { code } = runInstallWithSpinner(command, label, cwd);
374
+ if (code !== 0)
375
+ process.exit(code);
330
376
  }
331
377
  /**
332
378
  * Thrown by `runInstallOrThrow` when the install subprocess exits non-zero.
@@ -343,16 +389,14 @@ class InstallCommandError extends Error {
343
389
  }
344
390
  /**
345
391
  * Install variant that throws instead of calling `process.exit` on failure.
346
- * Used inside `launchOnMount`'s `onBeforeLaunch`, so mount teardown runs
392
+ * Used inside the mount branch's onBeforeLaunch step so mount teardown runs
347
393
  * before the error surfaces.
348
394
  */
349
395
  function runInstallOrThrow(command, label, cwd) {
350
- const [bin, ...args] = command;
396
+ const [bin] = command;
351
397
  if (!bin)
352
398
  return;
353
- process.stderr.write(`• ${label}\n`);
354
- const res = spawnSync(bin, args, { stdio: 'inherit', shell: false, cwd });
355
- const code = subprocessExitCode(res);
399
+ const { code } = runInstallWithSpinner(command, label, cwd);
356
400
  if (code !== 0) {
357
401
  throw new InstallCommandError(label, code);
358
402
  }
@@ -996,36 +1040,33 @@ async function runInteractive(selection, options) {
996
1040
  configFilePaths: spec.configFiles.map((file) => file.path)
997
1041
  });
998
1042
  process.stderr.write(`• sandbox mount → ${mountDir}\n`);
999
- // Three-stage SIGINT handler layered on top of launchOnMount's own signal
1000
- // forwarding. launchOnMount catches the first SIGINT to kill the child
1001
- // and run its finalize() (autoSync.stop + final syncBack), which walks
1002
- // both trees and can take several seconds on a large repo — during
1003
- // which the terminal otherwise looks frozen.
1043
+ // Inline mount lifecycle (formerly delegated to launchOnMount) so we can
1044
+ // surface a spinner the moment the child exits — not just when the user
1045
+ // presses Ctrl-C. The sync-back walks both trees and can take several
1046
+ // seconds on a large repo; without an indicator, exiting the persona via
1047
+ // /exit looked like a hang.
1004
1048
  //
1005
- // 1st press → start an ora spinner so the pause is visibly live
1006
- // (replaces the prior static print). onAfterSync below
1007
- // transitions the spinner into a succeed/fail state once
1008
- // relayfile reports the sync result.
1009
- // 2nd press → update the spinner text to the "aborting" warning and
1010
- // abort `shutdownSignal`, which local-mount 0.5+ respects
1011
- // by skipping autosync's draining reconcile and returning
1012
- // the partial count from the final syncBack. Cleanup
1013
- // still runs, so no leaked mount dir.
1014
- // 3rd press → hard escape: synchronously rm the mount root and
1015
- // process.exit(130) in case the abort never resolves.
1049
+ // SIGINT semantics:
1050
+ // While the child is running: Ctrl-C reaches the harness directly via
1051
+ // the controlling TTY's foreground process group (the child is
1052
+ // spawned with `stdio: 'inherit'` and inherits the parent's pgid). We
1053
+ // register a no-op handler here purely to suppress Node's default
1054
+ // exit-on-SIGINT forwarding via child.kill('SIGINT') would deliver
1055
+ // a *second* SIGINT and break harnesses that escalate on repeated
1056
+ // interrupts (e.g. claude treats 1st = cancel, 2nd = quit).
1057
+ // While syncing: 1st press aborts the shutdownSignal (relayfile then
1058
+ // skips autosync's draining reconcile and returns the partial count
1059
+ // from the final syncBack). 2nd press hard-exits and rms the session
1060
+ // dir so no mount is left behind.
1016
1061
  const shutdownController = new AbortController();
1017
- let sigintCount = 0;
1018
1062
  let syncSpinner;
1019
- const forceExitHandler = () => {
1020
- sigintCount += 1;
1021
- if (sigintCount === 1) {
1022
- syncSpinner = ora({
1023
- text: 'Syncing session changes back to the repo… (Ctrl-C again to skip)',
1024
- stream: process.stderr
1025
- }).start();
1063
+ let isSyncing = false;
1064
+ let abortPresses = 0;
1065
+ const sigintHandler = () => {
1066
+ if (!isSyncing)
1026
1067
  return;
1027
- }
1028
- if (sigintCount === 2) {
1068
+ abortPresses += 1;
1069
+ if (abortPresses === 1) {
1029
1070
  if (syncSpinner) {
1030
1071
  syncSpinner.text =
1031
1072
  'Aborting sync — partial changes will be propagated. (Ctrl-C again to force quit)';
@@ -1040,8 +1081,6 @@ async function runInteractive(selection, options) {
1040
1081
  else {
1041
1082
  process.stderr.write('\n✗ Force-quit: mount teardown skipped. Session dir may be left behind.\n');
1042
1083
  }
1043
- // Node-native removal rather than `rm -rf` so the emergency path
1044
- // works on Windows too.
1045
1084
  try {
1046
1085
  rmSync(sessionRoot, { recursive: true, force: true });
1047
1086
  }
@@ -1050,97 +1089,96 @@ async function runInteractive(selection, options) {
1050
1089
  }
1051
1090
  process.exit(130);
1052
1091
  };
1053
- process.on('SIGINT', forceExitHandler);
1092
+ process.on('SIGINT', sigintHandler);
1093
+ const handle = createMount(process.cwd(), mountDir, {
1094
+ ignoredPatterns: [...ignoredPatterns],
1095
+ readonlyPatterns: [...readonlyPatterns],
1096
+ excludeDirs: [],
1097
+ agentName: personaId,
1098
+ // Pull `.git` into the mount so git commands work inside the sandbox.
1099
+ // relayfile treats this as one-way project→mount: host-side `.git`
1100
+ // changes flow in, mount-side commits/refs stay sandboxed and are
1101
+ // discarded on cleanup. The agent must `git push` to persist work.
1102
+ includeGit: true
1103
+ });
1104
+ let autoSync;
1105
+ let exitCode = 0;
1054
1106
  try {
1055
- const result = await launchOnMount({
1056
- cli: spec.bin,
1057
- projectDir: process.cwd(),
1058
- mountDir,
1059
- args: finalArgs,
1060
- ignoredPatterns,
1061
- // launchOnMount passes `env` straight to the child spawn, so without
1062
- // merging process.env we'd strip PATH/HOME/etc. Match the non-clean
1063
- // branch: persona env overlays the inherited environment.
1064
- env: resolvedEnv ? { ...process.env, ...resolvedEnv } : process.env,
1065
- agentName: personaId,
1066
- // Pull `.git` into the mount so git commands work inside the
1067
- // sandbox. relayfile 0.6+ treats this as a one-way project→mount
1068
- // sync: host-side `.git` changes propagate in, mount-side commits/
1069
- // refs stay sandboxed and are discarded on cleanup. The agent must
1070
- // `git push` to persist work — local-only commits evaporate with
1071
- // the session.
1072
- includeGit: true,
1073
- readonlyPatterns,
1074
- // Second Ctrl-C aborts this signal → local-mount skips autosync's
1075
- // draining reconcile and returns the partial syncBack count. Cleanup
1076
- // still runs, so there's no leaked mount dir.
1077
- shutdownSignal: shutdownController.signal,
1078
- // Report sync stats so the user sees confirmation rather than a
1079
- // silent pause between the child exiting and the CLI returning.
1080
- //
1081
- // NOTE: `count` is bidirectional per relayfile's onAfterSync
1082
- // contract (see @relayfile/local-mount launch.d.ts) — it sums
1083
- // autosync activity in *both* directions (inbound project→mount
1084
- // and outbound mount→project, including deletes) plus the final
1085
- // mount→project syncBack. Phrasing this as "synced back to the
1086
- // repo" earlier misled sessions where inbound events dominated:
1087
- // a user who did no edits still saw "Synced 15 changes back"
1088
- // because ambient initial-mirror traffic counted. Phrase as "file
1089
- // events during session" so we don't overclaim direction.
1090
- onAfterSync: (count) => {
1091
- const aborted = shutdownController.signal.aborted;
1092
- const qualifier = aborted ? ' (partial)' : '';
1093
- const message = count > 0
1094
- ? `Session complete — ${count} file event${count === 1 ? '' : 's'} during session${qualifier}.`
1095
- : 'Session complete — no file events.';
1096
- if (syncSpinner) {
1097
- syncSpinner.succeed(message);
1098
- syncSpinner = undefined;
1099
- }
1100
- else {
1101
- process.stderr.write(`✓ ${message}\n`);
1102
- }
1103
- },
1104
- onBeforeLaunch: async (dir) => {
1105
- // Run before install / configFile writes so the freshly written
1106
- // files (e.g. `.opencode/`, `opencode.json`) aren't yet present
1107
- // when we run `git ls-files` to pick skip-worktree candidates —
1108
- // we don't need them flagged in the index, just hidden via the
1109
- // `.git/info/exclude` block.
1110
- configureGitForMount(dir, ignoredPatterns);
1111
- if (deferInstallToMount) {
1112
- runInstallOrThrow(install.command, installLabel, dir);
1113
- }
1114
- for (const file of spec.configFiles) {
1115
- assertSafeRelativePath(file.path);
1116
- const target = join(dir, file.path);
1117
- // mkdir -p for any subdirs in file.path — the
1118
- // InteractiveConfigFile contract allows nested relative
1119
- // paths, and writeFileSync would otherwise throw ENOENT.
1120
- mkdirSync(dirname(target), { recursive: true });
1121
- writeFileSync(target, file.contents, 'utf8');
1122
- }
1123
- if (resolvedSidecar) {
1124
- const body = buildSidecarBody(resolvedSidecar, process.cwd());
1125
- writeFileSync(join(dir, resolvedSidecar.mountFile), body, 'utf8');
1126
- }
1127
- launchMetadata = await startLaunchMetadataForLaunch(dir);
1128
- }
1107
+ // Run before install / configFile writes so the freshly written files
1108
+ // (e.g. `.opencode/`, `opencode.json`) aren't yet present when we run
1109
+ // `git ls-files` to pick skip-worktree candidates — we don't need them
1110
+ // flagged in the index, just hidden via the `.git/info/exclude` block.
1111
+ configureGitForMount(handle.mountDir, ignoredPatterns);
1112
+ if (deferInstallToMount) {
1113
+ runInstallOrThrow(install.command, installLabel, handle.mountDir);
1114
+ }
1115
+ for (const file of spec.configFiles) {
1116
+ assertSafeRelativePath(file.path);
1117
+ const target = join(handle.mountDir, file.path);
1118
+ mkdirSync(dirname(target), { recursive: true });
1119
+ writeFileSync(target, file.contents, 'utf8');
1120
+ }
1121
+ if (resolvedSidecar) {
1122
+ const body = buildSidecarBody(resolvedSidecar, process.cwd());
1123
+ writeFileSync(join(handle.mountDir, resolvedSidecar.mountFile), body, 'utf8');
1124
+ }
1125
+ launchMetadata = await startLaunchMetadataForLaunch(handle.mountDir);
1126
+ autoSync = handle.startAutoSync();
1127
+ const childEnv = resolvedEnv ? { ...process.env, ...resolvedEnv } : process.env;
1128
+ exitCode = await new Promise((resolve, reject) => {
1129
+ const child = spawn(spec.bin, finalArgs, {
1130
+ cwd: handle.mountDir,
1131
+ stdio: 'inherit',
1132
+ env: childEnv
1133
+ });
1134
+ child.on('error', reject);
1135
+ child.on('close', (code, signal) => {
1136
+ if (typeof code === 'number')
1137
+ resolve(code);
1138
+ else if (signal)
1139
+ resolve(signalExitCode(signal));
1140
+ else
1141
+ resolve(1);
1142
+ });
1129
1143
  });
1130
- return result.exitCode;
1144
+ // Child exited — start the spinner immediately so the sync-back is
1145
+ // visibly live rather than a silent pause.
1146
+ isSyncing = true;
1147
+ syncSpinner = ora({
1148
+ text: 'Syncing session changes back to the repo… (Ctrl-C to skip)',
1149
+ stream: process.stderr
1150
+ }).start();
1151
+ let count = 0;
1152
+ if (autoSync) {
1153
+ await autoSync.stop({ signal: shutdownController.signal });
1154
+ count += autoSync.totalChanges();
1155
+ autoSync = undefined;
1156
+ }
1157
+ // NOTE: `count` is bidirectional — it sums autosync activity in both
1158
+ // directions (inbound project→mount and outbound mount→project,
1159
+ // including deletes) plus the final mount→project syncBack. Phrase as
1160
+ // "file events during session" so we don't overclaim direction.
1161
+ count += await handle.syncBack({ signal: shutdownController.signal });
1162
+ const aborted = shutdownController.signal.aborted;
1163
+ const qualifier = aborted ? ' (partial)' : '';
1164
+ const message = count > 0
1165
+ ? `Session complete — ${count} file event${count === 1 ? '' : 's'} during session${qualifier}.`
1166
+ : 'Session complete — no file events.';
1167
+ syncSpinner.succeed(message);
1168
+ syncSpinner = undefined;
1169
+ return exitCode;
1131
1170
  }
1132
1171
  catch (err) {
1133
- // If the spinner is still live when we error out, mark it failed so
1134
- // the pending animation doesn't hang around under the error message.
1135
1172
  if (syncSpinner) {
1136
1173
  syncSpinner.fail('Sync did not complete');
1137
1174
  syncSpinner = undefined;
1138
1175
  }
1139
1176
  // InstallCommandError carries the real install exit code — surfacing
1140
1177
  // it (rather than collapsing onto 127) lets callers distinguish a
1141
- // failed `npx prpm install` from a missing harness binary.
1178
+ // failed `npx prpm install` from a missing harness binary. The message
1179
+ // itself was already shown via spinner.fail inside runInstallWithSpinner,
1180
+ // so we just return the code here.
1142
1181
  if (err instanceof InstallCommandError) {
1143
- process.stderr.write(`${err.message}. Aborting.\n`);
1144
1182
  return err.exitCode;
1145
1183
  }
1146
1184
  const e = err;
@@ -1152,21 +1190,29 @@ async function runInteractive(selection, options) {
1152
1190
  return 1;
1153
1191
  }
1154
1192
  finally {
1155
- // Defensive: if neither onAfterSync nor the catch branch stopped the
1156
- // spinner (e.g. unexpected exit path), stop it cleanly here so the
1157
- // terminal is not left in spinner state.
1158
1193
  if (syncSpinner) {
1159
1194
  syncSpinner.stop();
1160
1195
  syncSpinner = undefined;
1161
1196
  }
1197
+ // Best-effort: stop autosync if we errored out before the success path
1198
+ // already cleared it.
1199
+ if (autoSync) {
1200
+ try {
1201
+ await autoSync.stop({ signal: shutdownController.signal });
1202
+ }
1203
+ catch {
1204
+ /* ignore — we're tearing down anyway */
1205
+ }
1206
+ }
1207
+ handle.cleanup();
1162
1208
  await launchMetadata?.stop();
1163
- process.removeListener('SIGINT', forceExitHandler);
1209
+ process.removeListener('SIGINT', sigintHandler);
1164
1210
  // When the install ran inside the mount, its cleanup paths are
1165
- // mount-relative (e.g. `.skills/<name>`, `skills/<name>`) and
1166
- // running cleanup here would resolve them against the real repo
1167
- // cwd — potentially `rm -rf`ing pre-existing user content. The
1168
- // mount dir is removed wholesale by `removeSessionRoot` below, so
1169
- // the install's cleanup is redundant anyway in that case.
1211
+ // mount-relative (e.g. `.skills/<name>`, `skills/<name>`) and running
1212
+ // cleanup here would resolve them against the real repo cwd —
1213
+ // potentially `rm -rf`ing pre-existing user content. The mount dir is
1214
+ // removed wholesale by `removeSessionRoot` below, so the install's
1215
+ // cleanup is redundant anyway in that case.
1170
1216
  if (!deferInstallToMount) {
1171
1217
  runCleanup(install.cleanupCommand, install.cleanupCommandString);
1172
1218
  }
@@ -1510,7 +1556,8 @@ function collectPersonaRows() {
1510
1556
  intent: spec.intent,
1511
1557
  tags: spec.tags,
1512
1558
  description: spec.description,
1513
- rating: tier
1559
+ rating: tier,
1560
+ defaultTier: spec.defaultTier
1514
1561
  });
1515
1562
  }
1516
1563
  };
@@ -1659,7 +1706,7 @@ function runList(args) {
1659
1706
  return false;
1660
1707
  if (applyRecommended) {
1661
1708
  const rule = recommendedByIntent[r.intent];
1662
- if (r.rating !== (rule?.tier ?? 'best-value'))
1709
+ if (r.rating !== (rule?.tier ?? r.defaultTier ?? 'best-value'))
1663
1710
  return false;
1664
1711
  }
1665
1712
  return true;
@@ -1753,7 +1800,7 @@ function resolveShowTarget(selector, all) {
1753
1800
  }
1754
1801
  else {
1755
1802
  const rule = routingProfiles.default.intents[spec.intent];
1756
- tiers = [rule?.tier ?? 'best-value'];
1803
+ tiers = [rule?.tier ?? spec.defaultTier ?? 'best-value'];
1757
1804
  }
1758
1805
  return { spec, source, tiers, explicitTier };
1759
1806
  }
@@ -1770,6 +1817,9 @@ function formatPersonaShow(spec, source, tiers, tierNote) {
1770
1817
  lines.push(`INTENT ${spec.intent}`);
1771
1818
  lines.push(`TAGS ${spec.tags.length ? spec.tags.join(', ') : '(none)'}`);
1772
1819
  lines.push(`DESCRIPTION ${spec.description}`);
1820
+ if (spec.defaultTier) {
1821
+ lines.push(`DEFAULT TIER ${spec.defaultTier}`);
1822
+ }
1773
1823
  lines.push(`TIERS SHOWN ${tiers.join(', ')}${tierNote ? ` (${tierNote})` : ''}`);
1774
1824
  lines.push('');
1775
1825
  lines.push('SKILLS');