tonedial 0.1.1 → 0.1.3

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/cli.js CHANGED
@@ -1,14 +1,15 @@
1
1
  // Argument parsing, dispatch, and human/JSON output.
2
2
  import { parseArgs } from "node:util";
3
3
  import { isAbsolute } from "node:path";
4
- import { ConfigError, isConfigV2, nearest } from "./core/config.js";
4
+ import { ConfigError, SETTING_KEYS, isConfigV2, nearest } from "./core/config.js";
5
+ import { AXIS_ORDER } from "./core/anchors.js";
5
6
  import { compile } from "./core/compile.js";
6
7
  import { toCompileInput } from "./core/resolve.js";
7
8
  import { inspectContextLayout } from "./core/seeds.js";
8
9
  import { OperationError } from "./core/operations.js";
9
10
  import { RegionError } from "./wiring/region.js";
10
11
  import { connectionScopeFlag, installShape, resolveAgentPaths, resolvePaths } from "./core/paths.js";
11
- import { messages } from "./core/i18n.js";
12
+ import { catalogue, configLanguage, detectLanguage, messages } from "./core/i18n.js";
12
13
  import { isColourMode, palette } from "./ui/style.js";
13
14
  import { cells, maxCells, resolveBudget, wrap } from "./ui/width.js";
14
15
  import { interactive } from "./ui/prompt.js";
@@ -22,6 +23,7 @@ import { runSave } from "./commands/save.js";
22
23
  import { runCreate } from "./commands/create.js";
23
24
  import { runAddress, runPersona, runSet, runReset } from "./commands/set.js";
24
25
  import { runShow } from "./commands/show.js";
26
+ import { runGet, settingHelp } from "./commands/get.js";
25
27
  import { runUse } from "./commands/use.js";
26
28
  import { runDoctor } from "./commands/doctor.js";
27
29
  import { runEject } from "./commands/eject.js";
@@ -34,7 +36,7 @@ export const JSON_SCHEMA = 3;
34
36
  * run time is the one thing that breaks all three binary packagers at once. A test asserts this
35
37
  * equals the manifest, which is the cheap half of the trade.
36
38
  */
37
- export const VERSION = "0.1.1";
39
+ export const VERSION = "0.1.3";
38
40
  const FLAG_NAMES = ["project", "check", "explain", "global", "here", "migrateLegacy", "auto", "dryRun", "fix", "rollback", "fromOperation", "fromPreset", "restorePreset", "preset", "slug", "acceptUsage", "lang", "commands", "tools"];
39
41
  /** Command-line spelling for each flag, where it differs from the field name. */
40
42
  const FLAG_SPELLING = {
@@ -47,6 +49,7 @@ const FLAG_SPELLING = {
47
49
  /** Each command declares its accepted flags and positional limit; null means unlimited. */
48
50
  const COMMANDS = {
49
51
  set: { run: runSet, flags: [], max: null, context: "state" },
52
+ get: { run: runGet, flags: [], max: 1 },
50
53
  test: { run: runTest, flags: ["project", "dryRun", "preset", "acceptUsage"], max: 1 },
51
54
  address: { run: runAddress, flags: [], max: 2, context: "state" },
52
55
  persona: { run: runPersona, flags: [], max: 2, context: "state" },
@@ -77,7 +80,8 @@ export const COMMAND_NAMES = Object.keys(COMMANDS);
77
80
  /** Intent groups share one command inventory between compact and full help. */
78
81
  const COMMAND_GROUPS = [
79
82
  ["tone", [
80
- ["set", "set <axis>=<level> …"],
83
+ ["set", "set <key>=<value> …"],
84
+ ["get", "get [key]"],
81
85
  ["address", "address [self user]"],
82
86
  ["persona", "persona [agent user]"],
83
87
  ["reset", "reset [axis]"],
@@ -108,6 +112,7 @@ const COMMAND_GROUPS = [
108
112
  const COMMAND_SYNTAX = COMMAND_GROUPS.flatMap(([, rows]) => rows);
109
113
  const COMMAND_EXAMPLES = {
110
114
  set: ["tonedial set", "tonedial set warmth=5 critique=4", "tonedial set persona.agent=reviewer"],
115
+ get: ["tonedial get", "tonedial get language", "tonedial get speech_level --json"],
111
116
  test: ["tonedial test codex --dry-run", "tonedial test claude --project .", "tonedial test codex --preset my-tone --accept-usage"],
112
117
  address: ['tonedial address "assistant" "friend"'],
113
118
  persona: ["tonedial persona", 'tonedial persona "Fictional reviewer" "Developer"'],
@@ -216,7 +221,8 @@ function compactUsage(t, ui) {
216
221
  ].join("\n");
217
222
  }
218
223
  /** One command's syntax and only the flags that can affect that command. */
219
- function commandUsage(name, t, ui) {
224
+ function commandUsage(name, copy, ui) {
225
+ const t = copy.cli;
220
226
  const command = COMMANDS[name];
221
227
  const syntax = COMMAND_SYNTAX.find(([key]) => key === name)?.[1] ?? name;
222
228
  const flagKeys = [
@@ -231,6 +237,7 @@ function commandUsage(name, t, ui) {
231
237
  return [syntax, t.help.flags[key]];
232
238
  });
233
239
  const width = Math.max(...flags.map(([flag]) => cells(flag)));
240
+ const keyWidth = Math.max(...[...AXIS_ORDER, ...SETTING_KEYS].map(key => maxCells(key)));
234
241
  return [
235
242
  ui.bold(`tonedial ${syntax}`),
236
243
  "",
@@ -241,6 +248,12 @@ function commandUsage(name, t, ui) {
241
248
  "",
242
249
  ui.bold(t.help.examples),
243
250
  ...COMMAND_EXAMPLES[name].map(example => ` ${ui.accent(example)}`),
251
+ ...(["get", "set"].includes(name) ? [
252
+ "", ui.bold(t.artifact.whatEachAxisObserves),
253
+ ...helpRows(AXIS_ORDER.map(key => [key, settingHelp(key, copy).join(" · ")]), keyWidth, ui),
254
+ "", ui.bold(t.artifact.whatElseIsSettable),
255
+ ...helpRows(SETTING_KEYS.map(key => [key, settingHelp(key, copy).join(" · ")]), keyWidth, ui),
256
+ ] : []),
244
257
  ].join("\n");
245
258
  }
246
259
  /**
@@ -319,7 +332,8 @@ export async function run(argv, env = process.env, cwd = process.cwd(), isTTY =
319
332
  // Resolved before the mode is validated, so the message about a bad `--color` is itself
320
333
  // painted the way the rest of this invocation would have been.
321
334
  const configPath = resolvePaths(env).config;
322
- const say = messages(configPath, env);
335
+ const copy = catalogue(configLanguage(configPath) ?? detectLanguage(env));
336
+ const say = copy.cli;
323
337
  const colour = String(parsed.values.color);
324
338
  const mode = isColourMode(colour) ? colour : "auto";
325
339
  // The line budget is settled here beside the colour, because it is the same kind of decision:
@@ -353,7 +367,7 @@ export async function run(argv, env = process.env, cwd = process.cwd(), isTTY =
353
367
  if (!Object.hasOwn(COMMANDS, name)) {
354
368
  return fail(asJson, say.errors.notACommand(asJson ? name : humanText(name), COMMAND_NAMES, nearest(name, COMMAND_NAMES)), 2, uiErr);
355
369
  }
356
- process.stdout.write(`${commandUsage(name, say, ui)}\n`);
370
+ process.stdout.write(`${commandUsage(name, copy, ui)}\n`);
357
371
  return 0;
358
372
  }
359
373
  // npx supplies no durable bare command for generated controls. Help and version stay available.
@@ -503,7 +517,7 @@ function fail(asJson, message, code, ui) {
503
517
  }
504
518
  else {
505
519
  // A standalone path is a copy target, not prose to split into unusable fragments.
506
- const lines = message.split("\n").flatMap((line) => isAbsolute(line) || /^(?:tonedial|npm|curl) /.test(line) ? [humanText(line)] : wrap(humanText(line), ui.width));
520
+ const lines = message.split("\n").flatMap((line) => isAbsolute(line) || /^(?:tonedial|curl) |^npm (?:install|uninstall|update) -|^https?:\/\//.test(line) ? [humanText(line)] : wrap(humanText(line), ui.width));
507
521
  process.stderr.write(lines.map((line) => ui.error(line)).join("\n") + "\n");
508
522
  }
509
523
  return code;
@@ -11,7 +11,7 @@ import { guardTerminal } from "../ui/prompt.js";
11
11
  import { wrap } from "../ui/width.js";
12
12
  import { planConfig } from "./compile.js";
13
13
  import { savePreset } from "./save.js";
14
- import { applyAssignments } from "./set.js";
14
+ import { applyAssignments, settingsWarnings } from "./set.js";
15
15
  export async function runCreate(ctx, args) {
16
16
  try {
17
17
  const state = planConfig(ctx);
@@ -23,7 +23,7 @@ export async function runCreate(ctx, args) {
23
23
  config.persona.agent ??= defaults.agent;
24
24
  let displayName = args[0]?.trim() ?? "";
25
25
  let slug = ctx.flags.slug ?? undefined;
26
- const warnings = applyAssignments(config, args.slice(1), ctx.t, ctx.asJson).warnings;
26
+ applyAssignments(config, args.slice(1), ctx.t, ctx.asJson);
27
27
  if (ctx.canPrompt && !ctx.flags.auto && !ctx.flags.dryRun) {
28
28
  const release = guardTerminal();
29
29
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -60,10 +60,9 @@ export async function runCreate(ctx, args) {
60
60
  const chosen = await ask(label, value);
61
61
  try {
62
62
  const candidate = structuredClone(config);
63
- const applied = applyAssignments(candidate, [`${key}=${chosen}`], ctx.t, false);
63
+ applyAssignments(candidate, [`${key}=${chosen}`], ctx.t, false);
64
64
  serializeConfig(candidate, ctx.t);
65
65
  Object.assign(config, candidate);
66
- warnings.push(...applied.warnings);
67
66
  if (key === "persona.user" || key === "persona.agent") {
68
67
  process.stdout.write(` ${ctx.t.create.descriptionCount([...chosen].length, PERSONA_DESCRIPTION_MAX)}\n`);
69
68
  }
@@ -97,9 +96,10 @@ export async function runCreate(ctx, args) {
97
96
  return {
98
97
  ...result,
99
98
  json: { ...result.json, ...(result.json.created === true ? { config: toCompileInput(config) } : {}) },
100
- warnings: [...new Set([...warnings, ...(result.warnings ?? []), ...riskWarnings(config.axes, ctx.t),
101
- ...checkCombos(config.axes, config.persona, config.speech_level).map((notice) => combination(notice, ctx.t)),
102
- ctx.t.notice.modelExpectation])],
99
+ warnings: result.json.created === true ? [
100
+ ...settingsWarnings(config, ctx.t, ctx.asJson), ...riskWarnings(config.axes, ctx.t),
101
+ ...checkCombos(config.axes).map((notice) => combination(notice, ctx.t)),
102
+ ] : [],
103
103
  };
104
104
  }
105
105
  catch (cause) {
@@ -7,7 +7,7 @@ import { runRebuildNative } from "./wire.js";
7
7
  import { compile, measure } from "../core/compile.js";
8
8
  import { ConfigError, defaultConfig, isConfigV2, parseConfig } from "../core/config.js";
9
9
  import { inspectOperations, inspectRollback } from "../core/operations.js";
10
- import { join } from "node:path";
10
+ import { join, relative } from "node:path";
11
11
  import { AGENT_TOOLS, CONTEXT_ARTIFACT_FILENAME, CONTEXT_GUIDE_FILENAME, UPDATE_COMMAND, connectionScopeFlag, onPath, readerPath, resolveAgentPaths, shimScript, installShape, canonicalDestination, shellQuote } from "../core/paths.js";
12
12
  import { SKILL_NAME } from "../core/skill.js";
13
13
  import { GUIDE_FORMAT_MARKER } from "../core/reader.js";
@@ -681,9 +681,11 @@ async function doctorReport(ctx, showRollbackCommands = true, showCodexAction =
681
681
  const appHealthy = diagnosis.layout.kind === "current" && diagnosis.layout.cause === "none" && seeds.every(({ state }) => state === "current");
682
682
  const lines = [
683
683
  ` ${ctx.ui.bold((appHealthy ? ctx.ui.success : ctx.ui.warn)(ctx.t.doctor.applicationHealth(appHealthy)))}`,
684
- ...section(ctx.t.doctor.connectionsHeading, ctx.ui),
685
- ...wrap(ctx.t.doctor.connectionHealth(nativeAttention), ctx.ui.width - 2).map((line) => ` ${nativeAttention ? ctx.ui.warn(line) : line}`),
686
- ...wrap(ctx.t.doctor.hookSettingScope, ctx.ui.width - 2).map(line => ` ${line}`),
684
+ ...(integrations.length || nativeAttention ? [
685
+ ...section(ctx.t.doctor.connectionsHeading, ctx.ui),
686
+ ...wrap(ctx.t.doctor.connectionHealth(nativeAttention), ctx.ui.width - 2).map((line) => ` ${nativeAttention ? ctx.ui.warn(line) : line}`),
687
+ ...wrap(ctx.t.doctor.hookSettingScope, ctx.ui.width - 2).map(line => ` ${line}`),
688
+ ] : []),
687
689
  ...section(ctx.t.doctor.checksHeading, ctx.ui),
688
690
  ...renderCheckBlocks(shownChecks, ctx.flags.verbose ? [] : hookSettings, integrations, ctx),
689
691
  // A repair receipt presents its new IDs once in native→app order. Standalone diagnosis
@@ -753,8 +755,7 @@ async function runRollback(ctx, operationId) {
753
755
  * The seeds in home against the ones this package carries.
754
756
  *
755
757
  * An npm update replaces the code and never touches `~/.tonedial/`, so a corrected anchor would
756
- * otherwise never reach an existing user. Re-running `init` is the update path, and this is
757
- * what tells someone there is a reason to.
758
+ * otherwise never reach an existing user. `doctor --fix` refreshes generated application files.
758
759
  */
759
760
  function seedCheck(ctx, config, seeds) {
760
761
  // Zero means `init` never ran, which `use` and `wire` reach on their own and which nothing is
@@ -771,7 +772,7 @@ function seedCheck(ctx, config, seeds) {
771
772
  state: "problem",
772
773
  detail: ctx.t.doctor.seedsAttention,
773
774
  files: stale.map(({ seed, state }) => ({ path: seed.path, reason: state })),
774
- ...(stale.every(({ state }) => state === "missing" || state === "stale") ? { repair: repairCommand(ctx, "init") } : {}),
775
+ ...(stale.every(({ state }) => state === "missing" || state === "stale") ? { repair: repairCommand(ctx, "doctor --fix") } : {}),
775
776
  };
776
777
  }
777
778
  /** Whether the skill is linked, copied, someone else's, or simply not installed. */
@@ -1050,15 +1051,23 @@ function elsewhereReport(rows, ctx) {
1050
1051
  }
1051
1052
  function report(check, ctx) {
1052
1053
  const paint = check.state === "problem" ? ctx.ui.error : check.state === "ok" ? ctx.ui.success : ctx.ui.dim;
1053
- const head = ` ${paint(MARK[check.state])} ${ctx.ui.bold(humanText(check.name))}`;
1054
+ const head = ` ${paint(MARK[check.state])} ${ctx.ui.bold(humanText(check.name === "seeds" ? ctx.t.doctor.seedsHeading : check.name))}`;
1054
1055
  // Keep the marker and check name together, then give every detail a stable four-cell hanging
1055
1056
  // indent. A fixed name column made a narrow doctor report spend most of its width on padding.
1056
- const body = (check.humanDetail ?? [check.detail]).flatMap(detail => detail.split("\n"))
1057
+ const body = (check.files?.length ? [] : check.humanDetail ?? [check.detail]).flatMap(detail => detail.split("\n"))
1057
1058
  .flatMap(line => receiptLines(humanText(line), ctx.ui.width - 6));
1058
1059
  const lines = [head, ...body.map((line) => ` ${check.state === "ok" ? ctx.ui.dim(line) : line}`)];
1059
- for (const file of check.files ?? []) {
1060
- lines.push(...wrap(ctx.t.doctor.seedReason[file.reason], ctx.ui.width - 6).map((line) => ` ${line}`));
1061
- lines.push(` ${humanText(file.path)}`);
1060
+ if (check.files?.length) {
1061
+ lines.push(` ${humanText(ctx.paths.root)}`);
1062
+ for (const reason of ["missing", "stale", "modified", "foreign", "unreadable"]) {
1063
+ const files = check.files.filter(file => file.reason === reason);
1064
+ if (!files.length)
1065
+ continue;
1066
+ lines.push(...wrap(ctx.t.doctor.seedReason[reason], ctx.ui.width - 6).map(line => ` ${line}`));
1067
+ for (const file of files) {
1068
+ lines.push(...wrap(humanText(relative(ctx.paths.root, file.path)), ctx.ui.width - 8).map(line => ` ${line}`));
1069
+ }
1070
+ }
1062
1071
  }
1063
1072
  if (check.repair)
1064
1073
  lines.push(...nextStep(check.repair, ctx.ui));
@@ -1092,7 +1101,5 @@ function unconnectedReport(settings, ctx) {
1092
1101
  return [
1093
1102
  ` ${ctx.ui.dim(MARK.note)} ${ctx.ui.bold(ctx.t.doctor.hookGroupHeading)}`,
1094
1103
  ...wrap(ctx.t.doctor.hookGroupUnknown(tools), ctx.ui.width - 4).map(line => ` ${line}`),
1095
- ...wrap(ctx.t.doctor.hookGroupMissing, ctx.ui.width - 4).map(line => ` ${line}`),
1096
- ...wrap(ctx.t.doctor.hookGroupAction, ctx.ui.width - 4).map(line => ` ${line}`),
1097
1104
  ];
1098
1105
  }
@@ -11,6 +11,7 @@ import { check as checkCombos } from "../core/combos.js";
11
11
  import { artifactSize, combination, humanText, riskWarnings } from "../ui/format.js";
12
12
  import { captureRawEntry, planChange, rawReadChange, rawReadCheck } from "../wiring/apply.js";
13
13
  import { emptyWiring } from "../wiring/ownership.js";
14
+ import { settingsWarnings } from "./set.js";
14
15
  /**
15
16
  * The editor to hand the file to. `$VISUAL` wins over `$EDITOR` by long convention — a user
16
17
  * who sets both means the first for interactive work, which this is.
@@ -227,10 +228,9 @@ export async function runEdit(ctx, args) {
227
228
  }
228
229
  /** The notices every other state-changing command emits, through the same owners they use. */
229
230
  function stateWarnings(ctx, config) {
230
- const p = config.persona;
231
231
  return [
232
- ...checkCombos(config.axes, p, config.speech_level).map((w) => combination(w, ctx.t)),
233
- ...(p.age || p.gender || p.note || p.address || p.user || p.agent ? [ctx.t.notice.personaUnanchored] : []),
232
+ ...checkCombos(config.axes).map((w) => combination(w, ctx.t)),
233
+ ...settingsWarnings(config, ctx.t, ctx.asJson),
234
234
  ...riskWarnings(config.axes, ctx.t),
235
235
  ];
236
236
  }
@@ -0,0 +1,41 @@
1
+ // Read active settings without compiling, repairing, or interpreting stored text.
2
+ import { isAxis, AGE_BANDS, GENDERS, ConfigError, nearest, settingValues } from "../core/config.js";
3
+ import { LANGUAGES, catalogue, detectLanguage, isLanguage } from "../core/i18n.js";
4
+ import { humanText } from "../ui/format.js";
5
+ import { maxCells, padEnd, wrap } from "../ui/width.js";
6
+ import { planConfig } from "./compile.js";
7
+ /** The same descriptions and allowed tokens serve key reads and command help. */
8
+ export function settingHelp(key, copy) {
9
+ if (isAxis(key)) {
10
+ const hint = copy.axisHints[key];
11
+ return [hint.owns, `1–5 · 1 ${hint.low} · 5 ${hint.high}`];
12
+ }
13
+ const choices = key === "language" ? LANGUAGES : key === "persona.age" ? AGE_BANDS : key === "persona.gender" ? GENDERS : null;
14
+ return [copy.settingHints[key], ...(choices ? [choices.join(" | ")] : [])];
15
+ }
16
+ export function runGet(ctx, args) {
17
+ const { change, config } = planConfig(ctx);
18
+ if (change.before === null)
19
+ throw new ConfigError(ctx.t.test.missingConfig);
20
+ const all = settingValues(config);
21
+ const [requested] = args;
22
+ if (requested !== undefined && !Object.hasOwn(all, requested)) {
23
+ throw new ConfigError(ctx.t.axis.notSettable(ctx.asJson ? requested : humanText(requested), nearest(requested, Object.keys(all))));
24
+ }
25
+ const values = requested === undefined ? all : { [requested]: all[requested] };
26
+ const column = Math.max(...Object.keys(values).map(key => maxCells(key)));
27
+ const budget = ctx.ui.width - column - 4;
28
+ const lines = Object.entries(values).flatMap(([key, value]) => {
29
+ const text = value === null ? "—" : humanText(String(value));
30
+ if (budget < 12)
31
+ return [` ${ctx.ui.accent(key)}`, ...wrap(text, ctx.ui.width - 4).map(line => ` ${line}`)];
32
+ const [first, ...rest] = wrap(text, budget);
33
+ return [` ${ctx.ui.accent(padEnd(key, column))} ${first ?? ""}`,
34
+ ...rest.map(line => `${" ".repeat(column + 4)}${line}`)];
35
+ });
36
+ if (requested !== undefined) {
37
+ const copy = catalogue(isLanguage(config.language) ? config.language : detectLanguage(ctx.env));
38
+ lines.push("", ...settingHelp(requested, copy).flatMap(hint => wrap(hint, ctx.ui.width - 2).map(line => ` ${ctx.ui.dim(line)}`)));
39
+ }
40
+ return { lines, json: { values } };
41
+ }
@@ -219,11 +219,10 @@ export async function runInit(entry, _args) {
219
219
  addReceipt(message);
220
220
  }
221
221
  }
222
- lines.push("");
223
- addReceipt(copy.init.risk, ctx.ui.warn);
224
- addReceipt(ctx.t.notice.modelExpectation);
225
- for (const notice of riskWarnings(config.axes, ctx.t))
226
- addReceipt(notice, ctx.ui.risk);
222
+ if (applied) {
223
+ for (const notice of riskWarnings(config.axes, ctx.t))
224
+ addReceipt(notice, ctx.ui.risk);
225
+ }
227
226
  lines.push("");
228
227
  // Skills and controls invoke bare tonedial, so command reachability needs its own hint.
229
228
  if (onPath("tonedial", ctx.env) === null) {
@@ -298,10 +297,6 @@ async function rebuildExisting(entry) {
298
297
  const resultHeading = ctx.canPrompt && !ctx.flags.auto && !ctx.flags.dryRun
299
298
  ? section(copy.init.resultHeading, ctx.ui) : [];
300
299
  const app = await runAppRebuild(ctx);
301
- const riskIntro = [
302
- ...receiptLines(copy.init.risk, ctx.ui.width - 2).map(line => ` ${ctx.ui.warn(line)}`),
303
- ...receiptLines(ctx.t.notice.modelExpectation, ctx.ui.width - 2).map(line => ` ${line}`),
304
- ];
305
300
  if (ctx.flags.dryRun) {
306
301
  const answers = await ask(ctx, copy, false, selection);
307
302
  const tools = selection ?? answers.tools;
@@ -309,7 +304,7 @@ async function rebuildExisting(entry) {
309
304
  throw new ConfigError(ctx.t.errors.commandsNeedClaude);
310
305
  const native = await runRebuildNative({ ...ctx, flags: { ...ctx.flags, tools: tools.length ? tools.join(",") : "none" } }, app.legacy, app.proposedConfig);
311
306
  return {
312
- lines: [...(ctx.canPrompt ? [] : introduction), ...app.lines, ...native.lines, ...riskIntro],
307
+ lines: [...(ctx.canPrompt ? [] : introduction), ...app.lines, ...native.lines],
313
308
  json: { ...app.json, agentRoot: ctx.agent.root, live: ctx.agent.live,
314
309
  scope: ctx.agent.live ? "global" : "project", scopeExplicit, language: app.proposedConfig.language,
315
310
  seedVersion: SEED_VERSION, tools, native: native.json,
@@ -320,7 +315,7 @@ async function rebuildExisting(entry) {
320
315
  };
321
316
  }
322
317
  if (app.rebuiltConfig === null)
323
- return { ...app, lines: [...(ctx.canPrompt ? [] : introduction), ...resultHeading, ...app.lines, ...riskIntro] };
318
+ return { ...app, lines: [...(ctx.canPrompt ? [] : introduction), ...resultHeading, ...app.lines] };
324
319
  const answers = await ask(ctx, copy, false, selection);
325
320
  const tools = selection ?? answers.tools;
326
321
  if (ctx.flags.commands && !tools.includes("claude"))
@@ -331,7 +326,6 @@ async function rebuildExisting(entry) {
331
326
  const after = codexHandoff(scoped, native.json.applied === true || native.json.reason === "nothing", codexConfigured);
332
327
  return {
333
328
  lines: [...(ctx.canPrompt ? [] : introduction), ...resultHeading, ...app.lines, ...native.lines,
334
- ...riskIntro,
335
329
  ...recoveryRollbackLines(ctx.t, app.json.operationId, native.json.operationId)],
336
330
  ...(after ? { after } : {}),
337
331
  json: {
@@ -60,7 +60,7 @@ export async function savePreset(ctx, displayName, state, options = {}) {
60
60
  [address?.self_ref ? ctx.t.persona.callsItself(humanText(address.self_ref)) : "",
61
61
  address?.user_ref ? ctx.t.persona.callsYou(humanText(address.user_ref)) : ""].filter(Boolean).join(" · "),
62
62
  ...["user", "agent"].map((field) => `${ctx.t.persona.descriptionFull(field, humanText(config.persona[field] ?? ""))} (${ctx.t.create.descriptionCount([...(config.persona[field] ?? "")].length, PERSONA_DESCRIPTION_MAX)})`),
63
- `${(config.safeword ? ctx.t.settings.safewordSet(humanText(config.safeword)) : ctx.t.settings.noSafeword).split(" — ")[0]} · ${ctx.t.settings.register(config.speech_level)}`,
63
+ `${config.safeword ? ctx.t.settings.safewordSet(humanText(config.safeword)) : ctx.t.settings.noSafeword} · ${ctx.t.settings.register(config.speech_level)}`,
64
64
  ];
65
65
  for (const detail of details.filter(Boolean))
66
66
  review.push(...wrap(detail, ctx.ui.width - 2).map((line) => ` ${line}`));
@@ -73,20 +73,13 @@ export async function savePreset(ctx, displayName, state, options = {}) {
73
73
  };
74
74
  const count = Object.keys(values).length;
75
75
  const countLabel = ctx.t.preset.nonDefault(count);
76
- const warnings = [];
77
- if (replaced)
78
- warnings.push(ctx.t.preset.replaced(slug));
79
- // Sparsity survives on one reason after the omit-defaults rule fell: fewer extremes mean
80
- // fewer internal collisions. It is guidance, so it warns and never blocks.
81
- if (count < 3 || count > 7) {
82
- warnings.push(ctx.t.preset.sparsity(slug, count));
83
- }
84
76
  return {
85
77
  // Truncated, not folded. A display name has no length cap, and folding one across two lines
86
78
  // makes the slug beside it harder to read than losing the tail does.
87
79
  lines: [
88
80
  ...(options.create ? result.lines : []),
89
- ` ${ctx.ui.success(ctx.ui.bold(ctx.t.preset.savedTarget(slug)))}`,
81
+ ...wrap(replaced ? ctx.t.preset.replaced(slug) : ctx.t.preset.savedTarget(slug), ctx.ui.width - 2)
82
+ .map((line) => ` ${ctx.ui.success(ctx.ui.bold(line))}`),
90
83
  ` ${truncate(humanText(displayName), Math.max(1, ctx.ui.width - cells(` ${countLabel}`)))} ${ctx.ui.dim(countLabel)}`,
91
84
  ` ${ctx.t.artifact.generated}`,
92
85
  ...(ctx.flags.verbose ? [` ${ctx.ui.dim(artifactSize(size, ctx.t))}`] : []),
@@ -94,6 +87,5 @@ export async function savePreset(ctx, displayName, state, options = {}) {
94
87
  ],
95
88
  json: { slug, displayName, values, settings: preset.settings, nonDefaultAxes: count, replaced, chars: size.chars,
96
89
  ...(options.create ? { created: true, diff: result.diff, operationId: result.operationId } : {}) },
97
- warnings,
98
90
  };
99
91
  }
@@ -5,7 +5,7 @@
5
5
  // job is to say so. Every selected level remains unchanged, including contradictions.
6
6
  import { AXIS_DEFAULT, AXIS_ORDER, riskyAxes } from "../core/anchors.js";
7
7
  import { check as checkCombos, PERSONA_DESCRIPTION_MAX } from "../core/combos.js";
8
- import { AGE_BANDS, GENDERS, NOTE_MAX, SPEECH_LEVELS, ConfigError, SETTING_KEYS, isAxis, nearest, nearestAxis, serializeConfig, } from "../core/config.js";
8
+ import { AGE_BANDS, GENDERS, SPEECH_LEVELS, ConfigError, SETTING_KEYS, isAxis, nearest, nearestAxis, serializeConfig, settingValues, } from "../core/config.js";
9
9
  import { toCompileInput } from "../core/resolve.js";
10
10
  import { LANGUAGES, catalogue, isLanguage } from "../core/i18n.js";
11
11
  import { presetSettings, resolvePreset } from "../core/presets.js";
@@ -31,36 +31,15 @@ function bucketAge(value, t, asJson) {
31
31
  }
32
32
  /** The settable persona keys, in the order `set --help` and the error message list them. */
33
33
  const PERSONA_FIELDS = ["age", "gender", "self_ref", "user_ref", "note", "user", "agent"];
34
- const OBSERVABLE_WORDS = {
35
- formal: "formality", polite: "formality", casual: "formality",
36
- brief: "verbosity", concise: "verbosity", verbose: "verbosity", short: "verbosity",
37
- swear: "profanity", profan: "profanity",
38
- joke: "playfulness", funny: "playfulness", humour: "playfulness", humor: "playfulness",
39
- harsh: "critique", critic: "critique", blunt: "critique",
40
- praise: "flattery", compliment: "flattery", flatter: "flattery",
41
- warm: "warmth", kind: "warmth", empath: "warmth",
42
- opinion: "conviction", decisive: "conviction",
43
- thorough: "care", proactive: "care",
44
- obedient: "compliance", grumble: "compliance",
45
- refuse: "refusal", boast: "bravado", brag: "bravado",
46
- };
47
- /** The note is a content channel, so `set` says what an axis would own better. It never edits. */
48
- function noteWarnings(note, t) {
49
- const out = [];
50
- if ([...note].length > NOTE_MAX) {
51
- out.push(t.persona.noteOverCap([...note].length, NOTE_MAX));
52
- }
53
- const lower = note.toLowerCase();
54
- const named = [...new Set(Object.entries(OBSERVABLE_WORDS)
55
- .filter(([word]) => lower.includes(word))
56
- .map(([, axis]) => axis))];
57
- if (named.length) {
58
- out.push(t.persona.noteNamesAxis(named));
34
+ /** Report limitations in the final settings, never superseded interview answers. */
35
+ export function settingsWarnings(config, t, asJson) {
36
+ const warnings = [];
37
+ if (config.safeword && [...config.safeword].length < 4) {
38
+ warnings.push(t.settings.safewordShort(asJson ? config.safeword : humanText(config.safeword)));
59
39
  }
60
- return out;
40
+ return warnings;
61
41
  }
62
42
  function assign(config, key, value, t, asJson) {
63
- const warnings = [];
64
43
  if (key === "guards") {
65
44
  throw new ConfigError(t.settings.guardsRetired);
66
45
  }
@@ -70,23 +49,20 @@ function assign(config, key, value, t, asJson) {
70
49
  if (key === "safeword") {
71
50
  const phrase = value.trim();
72
51
  config.safeword = phrase === "" ? null : phrase;
73
- if (config.safeword && [...config.safeword].length < 4) {
74
- warnings.push(t.settings.safewordShort(asJson ? config.safeword : humanText(config.safeword)));
75
- }
76
- return warnings;
52
+ return;
77
53
  }
78
54
  if (key === "speech_level") {
79
55
  if (!SPEECH_LEVELS.includes(value)) {
80
56
  throw new ConfigError(t.settings.speechLevel(SPEECH_LEVELS));
81
57
  }
82
58
  config.speech_level = value;
83
- return warnings;
59
+ return;
84
60
  }
85
61
  if (key === "language") {
86
62
  if (!isLanguage(value))
87
63
  throw new ConfigError(t.errors.languageUnsupported(asJson ? value : humanText(value), LANGUAGES));
88
64
  config.language = value;
89
- return warnings;
65
+ return;
90
66
  }
91
67
  if (key.startsWith("persona.")) {
92
68
  const field = key.slice("persona.".length);
@@ -109,8 +85,6 @@ function assign(config, key, value, t, asJson) {
109
85
  break;
110
86
  case "note":
111
87
  config.persona.note = empty ? null : value;
112
- if (!empty)
113
- warnings.push(...noteWarnings(value, t));
114
88
  break;
115
89
  case "user":
116
90
  case "agent": {
@@ -132,7 +106,7 @@ function assign(config, key, value, t, asJson) {
132
106
  // first. An axis error already states its range; this one owed the same.
133
107
  throw new ConfigError(t.persona.unknownField(asJson ? field : humanText(field), PERSONA_FIELDS));
134
108
  }
135
- return warnings;
109
+ return;
136
110
  }
137
111
  if (key.startsWith("subagent.")) {
138
112
  throw new ConfigError(t.axis.subagentUnsupported);
@@ -154,10 +128,9 @@ function assign(config, key, value, t, asJson) {
154
128
  config.axes[key] = level;
155
129
  config.unsaved = true;
156
130
  }
157
- return warnings;
158
131
  }
159
132
  function comboWarnings(config, t) {
160
- return checkCombos(config.axes, config.persona, config.speech_level).map((w) => combination(w, t));
133
+ return checkCombos(config.axes).map((w) => combination(w, t));
161
134
  }
162
135
  const UNSET = "—";
163
136
  /**
@@ -184,33 +157,19 @@ function movementLines(moves, ctx, axes) {
184
157
  * Returns `undefined` for a key this tool does not have, which `assign` then rejects by name.
185
158
  */
186
159
  function readKey(config, key, t) {
187
- if (key === "safeword")
188
- return config.safeword ?? UNSET;
189
- if (key === "speech_level")
190
- return config.speech_level;
191
- if (key === "language")
192
- return config.language;
193
- if (key.startsWith("persona.")) {
194
- const field = key.slice("persona.".length);
195
- if (field === "age")
196
- return config.persona.age ?? UNSET;
197
- if (field === "gender")
198
- return config.persona.gender ?? UNSET;
199
- if (field === "self_ref")
200
- return config.persona.address?.self_ref ?? UNSET;
201
- if (field === "user_ref")
202
- return config.persona.address?.user_ref ?? UNSET;
203
- if (field === "note") {
204
- const note = config.persona.note;
205
- return note ? t.persona.noteChars([...note].length) : UNSET;
206
- }
207
- if (field === "user" || field === "agent") {
208
- const description = config.persona[field];
209
- return description ? t.persona.descriptionChars(field, [...description].length) : UNSET;
210
- }
160
+ const values = settingValues(config);
161
+ if (!Object.hasOwn(values, key))
211
162
  return undefined;
163
+ const value = values[key];
164
+ if (value === null)
165
+ return UNSET;
166
+ if (typeof value === "string") {
167
+ if (key === "persona.note")
168
+ return t.persona.noteChars([...value].length);
169
+ if (key === "persona.user" || key === "persona.agent")
170
+ return t.persona.descriptionChars(key.slice("persona.".length), [...value].length);
212
171
  }
213
- return isAxis(key) ? String(config.axes[key]) : undefined;
172
+ return String(value);
214
173
  }
215
174
  /**
216
175
  * Assign each pair, and record the movement each one actually made.
@@ -221,16 +180,12 @@ function readKey(config, key, t) {
221
180
  */
222
181
  function applyPairs(config, pairs, t, asJson) {
223
182
  const settingsBefore = JSON.stringify(presetSettings(config));
224
- const warnings = [];
225
183
  const changed = [];
226
184
  const moves = [];
227
- let personaTouched = false;
228
185
  for (const [key, value] of pairs) {
229
186
  const before = readKey(config, key, t);
230
- warnings.push(...assign(config, key, value, t, asJson));
187
+ assign(config, key, value, t, asJson);
231
188
  changed.push(key);
232
- if (key.startsWith("persona.") && value.trim() !== "")
233
- personaTouched = true;
234
189
  const after = readKey(config, key, t);
235
190
  // Only a real move. `set formality=3` on an axis already at 3 was reporting `3 → 3`, which
236
191
  // is the misleading `from` this line exists to prevent.
@@ -240,9 +195,7 @@ function applyPairs(config, pairs, t, asJson) {
240
195
  }
241
196
  if (settingsBefore !== JSON.stringify(presetSettings(config)))
242
197
  config.unsaved = true;
243
- if (personaTouched)
244
- warnings.push(t.notice.personaUnanchored);
245
- return { warnings, changed, moves };
198
+ return { warnings: settingsWarnings(config, t, asJson), changed, moves };
246
199
  }
247
200
  /** The same assignments and validation for direct tuning and preset creation. */
248
201
  export function applyAssignments(config, args, t, asJson) {
@@ -276,18 +229,14 @@ async function commit(ctx, configChange, config, applied) {
276
229
  };
277
230
  }
278
231
  export async function runSet(ctx, args) {
279
- let result;
280
232
  if (!args.length) {
281
233
  if (!ctx.canPrompt || !interactive()) {
282
234
  throw new ConfigError(ctx.t.axis.needsArgument);
283
235
  }
284
- result = await runDial(ctx);
236
+ return runDial(ctx);
285
237
  }
286
- else {
287
- const { change: configChange, config } = planConfig(ctx);
288
- result = await commit(ctx, configChange, config, applyAssignments(config, args, ctx.t, ctx.asJson));
289
- }
290
- return { ...result, warnings: [...(result.warnings ?? []), ctx.t.notice.modelExpectation] };
238
+ const { change: configChange, config } = planConfig(ctx);
239
+ return commit(ctx, configChange, config, applyAssignments(config, args, ctx.t, ctx.asJson));
291
240
  }
292
241
  /**
293
242
  * `tonedial address` — the two terms, and nothing else.