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.
@@ -5,7 +5,7 @@ import { isAbsolute } from "node:path";
5
5
  import { stripVTControlCharacters } from "node:util";
6
6
  import { AXIS_DEFAULT, AXIS_GROUP, AXIS_KIND, AXIS_RISK_FROM, riskyAxes } from "../core/anchors.js";
7
7
  import { check as checkCombos, PERSONA_DESCRIPTION_MAX } from "../core/combos.js";
8
- import { ConfigError, isConfigV2 } from "../core/config.js";
8
+ import { ConfigError, isConfigV2, settingValues } from "../core/config.js";
9
9
  import { compile, measure } from "../core/compile.js";
10
10
  import { withPreset, matchesPreset, inspectPresetLibrary, resolvePreset } from "../core/presets.js";
11
11
  import { toCompileInput } from "../core/resolve.js";
@@ -31,6 +31,7 @@ export function runShow(ctx, args) {
31
31
  }
32
32
  const shown = preview ? withPreset(config, preview) : config;
33
33
  const axes = shown.axes;
34
+ const values = settingValues(shown);
34
35
  const input = toCompileInput(shown);
35
36
  const artifact = compile(input);
36
37
  const size = measure(artifact);
@@ -63,33 +64,37 @@ export function runShow(ctx, args) {
63
64
  for (const folded of wrap(sizeLabel, ui.width - 2))
64
65
  lines.push(` ${ui.dim(folded)}`);
65
66
  }
66
- const p = shown.persona;
67
- const personaSet = Boolean(p.age || p.gender || p.note || p.address || p.user || p.agent);
67
+ const { "persona.age": age, "persona.gender": gender, "persona.note": note, "persona.self_ref": selfRef, "persona.user_ref": userRef, "persona.user": user, "persona.agent": agent } = values;
68
+ const personaSet = Boolean(age || gender || note || selfRef || userRef || user || agent);
68
69
  const gauges = [gaugeHeader(ui, ctx.t), ...gaugeBlock(axes, ui)];
69
70
  const gaugeWidth = Math.max(...gauges.map((line) => maxCells(stripVTControlCharacters(line))));
70
71
  const detailWidth = ui.terminal && ui.width >= 119 ? ui.width - gaugeWidth - 4 : ui.width - 2;
71
72
  const details = [ui.bold(ctx.t.artifact.settingsHeading)];
72
73
  if (personaSet) {
73
74
  const bits = [
74
- p.age ? humanText(p.age) : null,
75
- p.gender ? humanText(p.gender) : null,
76
- p.address?.self_ref ? ctx.t.persona.callsItself(humanText(p.address.self_ref)) : null,
77
- p.address?.user_ref ? ctx.t.persona.callsYou(humanText(p.address.user_ref)) : null,
78
- p.note ? ctx.t.persona.noteChars([...p.note].length) : null,
79
- p.user && p.agent ? ctx.t.persona.descriptionCounts([...p.user].length, [...p.agent].length, PERSONA_DESCRIPTION_MAX) : null,
80
- p.user && !p.agent ? ctx.t.persona.descriptionChars("user", [...p.user].length) : null,
81
- p.agent && !p.user ? ctx.t.persona.descriptionChars("agent", [...p.agent].length) : null,
75
+ age ? humanText(age) : null,
76
+ gender ? humanText(gender) : null,
77
+ selfRef ? ctx.t.persona.callsItself(humanText(selfRef)) : null,
78
+ userRef ? ctx.t.persona.callsYou(humanText(userRef)) : null,
79
+ note ? ctx.t.persona.noteChars([...note].length) : null,
80
+ user && agent ? ctx.t.persona.descriptionCounts([...user].length, [...agent].length, PERSONA_DESCRIPTION_MAX) : null,
81
+ user && !agent ? ctx.t.persona.descriptionChars("user", [...user].length) : null,
82
+ agent && !user ? ctx.t.persona.descriptionChars("agent", [...agent].length) : null,
82
83
  ].filter(Boolean);
83
84
  if (bits.length)
84
85
  for (const folded of wrap(bits.join(" · "), detailWidth))
85
86
  details.push(ui.dim(folded));
86
- for (const [field, value] of [["agent", p.agent], ["user", p.user]]) {
87
+ for (const [field, value] of [["agent", agent], ["user", user]]) {
87
88
  if (value === null)
88
89
  continue;
89
90
  for (const folded of wrap(ctx.t.persona.descriptionFull(field, humanText(value)), detailWidth)) {
90
91
  details.push(ui.dim(folded));
91
92
  }
92
93
  }
94
+ if (note !== null) {
95
+ for (const folded of wrap(ctx.t.persona.descriptionFull("note", humanText(note)), detailWidth))
96
+ details.push(ui.dim(folded));
97
+ }
93
98
  }
94
99
  // Folded rather than laid out: the safeword is user text of unbounded length, so this row
95
100
  // can always exceed the budget and no amount of shortening the fixed words prevents it.
@@ -97,7 +102,7 @@ export function runShow(ctx, args) {
97
102
  ? [...new Set(Object.values(config.wiring.registrations).map((record) => `${record.target} (${record.scope})`))]
98
103
  : Object.keys(config.wiring);
99
104
  for (const text of [
100
- stateRow(shown, ctx.t), ctx.t.artifact.displaySettings(config.language),
105
+ stateRow(values, ctx.t), ctx.t.artifact.displaySettings(values.language),
101
106
  ctx.t.artifact.connectionRecords(records.map(humanText)),
102
107
  ])
103
108
  for (const folded of wrap(text, detailWidth))
@@ -148,13 +153,9 @@ export function runShow(ctx, args) {
148
153
  const [first, ...rest] = wrap(legend, ui.width - 4);
149
154
  lines.push(` ${ui.accent("*")} ${ui.dim(first)}`, ...rest.map((line) => ` ${ui.dim(line)}`), ...nextStep(preview ? `tonedial use ${preview.slug}` : needsSave ? saveCommand : "tonedial show --explain", ui).slice(1));
150
155
  }
151
- const combinations = checkCombos(axes, shown.persona, shown.speech_level);
156
+ const combinations = checkCombos(axes);
152
157
  const warnings = combinations.map((w) => combination(w, ctx.t));
153
- if (personaSet) {
154
- warnings.push(ctx.t.notice.personaUnanchored);
155
- }
156
158
  warnings.push(...riskWarnings(axes, ctx.t));
157
- warnings.push(ctx.t.notice.modelExpectation);
158
159
  if (activeConflict && preview === null)
159
160
  warnings.push(ctx.t.preset.activeConflict(config.preset));
160
161
  return {
@@ -174,10 +175,10 @@ export function runShow(ctx, args) {
174
175
  riskAxes: riskyAxes(axes),
175
176
  riskThresholds: AXIS_RISK_FROM,
176
177
  persona: shown.persona,
177
- safeword: shown.safeword,
178
- speech_level: shown.speech_level,
178
+ safeword: values.safeword,
179
+ speech_level: values.speech_level,
179
180
  // Interface language never changes the compiled tone.
180
- language: config.language,
181
+ language: values.language,
181
182
  chars: size.chars,
182
183
  artifact: { exists: artifactOnDisk },
183
184
  },
@@ -201,9 +202,9 @@ function titleRow(name, sizeLabel, ctx) {
201
202
  * Safeword and register on one row. Plain text, because it is folded before it is
202
203
  * painted and an escape sequence measures as characters while occupying no cell.
203
204
  */
204
- function stateRow(config, t) {
205
- const safeword = config.safeword ? t.settings.safewordSet(humanText(config.safeword)).split(" — ")[0] : t.settings.noSafeword.split(" — ")[0];
206
- return `${safeword} · ${t.settings.register(config.speech_level)}`;
205
+ function stateRow(values, t) {
206
+ const safeword = values.safeword ? t.settings.safewordSet(humanText(values.safeword)) : t.settings.noSafeword;
207
+ return `${safeword} · ${t.settings.register(values.speech_level)}`;
207
208
  }
208
209
  // Presence alone establishes neither current contents nor native loading.
209
210
  function artifactExists(ctx) {
@@ -141,6 +141,5 @@ export async function runImport(ctx, args) {
141
141
  ...nextStep(`tonedial show ${slug}`, ctx.ui),
142
142
  ],
143
143
  json: { slug, displayName: preset.displayName, replaced: existed, source },
144
- warnings: existed ? [ctx.t.preset.replaced(slug)] : [],
145
144
  };
146
145
  }
@@ -1,13 +1,13 @@
1
- // Update curl installations, guide npm updates, and report generated-state freshness.
1
+ // Update through the installation owner and report application-state work separately.
2
2
  import { VERSION } from "../cli.js";
3
3
  import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "node:fs";
4
4
  import { TextDecoder } from "node:util";
5
5
  import { ConfigError, parseConfig } from "../core/config.js";
6
- import { AGENT_TOOLS, UPDATE_COMMAND, installShape } from "../core/paths.js";
7
- import { REGISTRY, downloadRelease, haveTar, installRelease, installedVersion, latestRelease } from "../core/selfupdate.js";
6
+ import { AGENT_TOOLS, UPDATE_COMMAND, installShape, shellQuote } from "../core/paths.js";
7
+ import { REGISTRY, downloadRelease, haveTar, inspectNpmInstall, installRelease, installedVersion, latestRelease, updateNpmInstall } from "../core/selfupdate.js";
8
8
  import { confirm } from "../ui/prompt.js";
9
9
  import { SEED_VERSION, inspectSeeds, recordedSeedSelection } from "../core/seeds.js";
10
- import { humanText } from "../ui/format.js";
10
+ import { humanText, receiptLines } from "../ui/format.js";
11
11
  import { urlIdentity } from "../core/net.js";
12
12
  /** A damaged state file must not disable package guidance or reveal its parser excerpt. */
13
13
  function updateConfig(ctx) {
@@ -84,16 +84,40 @@ export async function runUpdate(ctx) {
84
84
  stateIssue = "unreadable";
85
85
  }
86
86
  }
87
- const lines = [
88
- ` ${ctx.ui.bold(ctx.t.update.running(VERSION))}`,
89
- ` ${ctx.ui.dim(ctx.t.update.installedBy(shape))}`,
90
- "",
91
- ];
92
- // npm is told, not driven. Spawning a package manager to update a package it owns is a
93
- // different act from replacing a directory this tool put there itself.
87
+ const lines = [` ${ctx.ui.bold(ctx.t.update.running(VERSION))}`, ""];
88
+ const preview = () => {
89
+ if (!ctx.asJson) {
90
+ process.stdout.write(`${lines.join("\n")}\n`);
91
+ lines.length = 0;
92
+ }
93
+ };
94
94
  let replaced = null;
95
+ let installed = null;
96
+ let outcome;
95
97
  if (shape !== "curl") {
96
- lines.push(` ${ctx.t.update.onlyCurl(shape)}`, ` ${ctx.t.update.toUpdate}`, ` ${ctx.ui.accent(command)}`);
98
+ if (ctx.flags.dryRun) {
99
+ outcome = "dry-run";
100
+ lines.push(` ${ctx.t.update.toUpdate}`, ` ${ctx.ui.accent(command)}`, ` ${ctx.t.wiring.dryRun}`);
101
+ }
102
+ else if (!ctx.flags.auto && !ctx.canPrompt) {
103
+ outcome = "approval-required";
104
+ lines.push(` ${ctx.t.update.toUpdate}`, ` ${ctx.ui.accent(command)}`, ...receiptLines(ctx.t.update.needsApproval, ctx.ui.width - 2).map(line => ` ${line}`));
105
+ }
106
+ else {
107
+ const target = inspectNpmInstall(ctx.env, ctx.t);
108
+ lines.push(` ${ctx.t.update.toUpdate}`, ` ${ctx.ui.accent(`${command} --prefix "${shellQuote(humanText(target.prefix))}"`)}`);
109
+ preview();
110
+ if (ctx.flags.auto || (await confirm(ctx.t.update.npmQuestion)) === "yes") {
111
+ installed = updateNpmInstall(target, ctx.env, ctx.t);
112
+ replaced = installed === target.version ? null : installed;
113
+ outcome = replaced === null ? "unchanged" : "updated";
114
+ lines.push(` ${ctx.ui.success(ctx.t.update.npmComplete(installed))}`);
115
+ }
116
+ else {
117
+ outcome = "declined";
118
+ lines.push(` ${ctx.t.update.declined}`);
119
+ }
120
+ }
97
121
  }
98
122
  else {
99
123
  // Before the network, like `install.sh` checks its tools before it fetches anything.
@@ -104,29 +128,38 @@ export async function runUpdate(ctx) {
104
128
  if (release.version === VERSION) {
105
129
  // Nothing downloaded. The common case is an install that is already current, and paying
106
130
  // for the whole package to be told so is a cost with nothing on the other side of it.
131
+ installed = VERSION;
132
+ outcome = "unchanged";
107
133
  lines.push(` ${ctx.ui.success(ctx.t.update.already(release.version))}`);
108
134
  }
109
135
  else if (ctx.flags.dryRun) {
110
- lines.push(` ${ctx.ui.warn(ctx.t.update.wouldReplace(humanText(ctx.paths.lib), humanText(release.version)))}`);
136
+ outcome = "dry-run";
137
+ lines.push(` ${ctx.t.update.wouldReplace(humanText(ctx.paths.lib), humanText(release.version))}`);
111
138
  }
112
139
  else if (!ctx.flags.auto && !ctx.canPrompt) {
113
140
  // JSON never asks, including on a terminal. Explicit --auto supplies approval through
114
141
  // the same branch as human output; absent approval, report the existing no-install result.
115
- lines.push(` ${ctx.ui.warn(ctx.t.update.needsApproval)}`);
116
- }
117
- else if (ctx.flags.auto || (await confirm(ctx.t.update.question(release.version))) === "yes") {
118
- const tarball = await downloadRelease(release, ctx.t);
119
- lines.push(` ${ctx.ui.accent(ctx.t.update.found(release.version, tarball.byteLength))}`);
120
- installRelease(ctx.paths, tarball, ctx.t);
121
- // **What landed, not what was claimed.** The registry's `version` is metadata; a stub or
122
- // a mistake serving one number and a different tarball made `update` report a version it
123
- // had not installed — and, because the no-op check compares that metadata against this
124
- // process's own constant, swap again on every run forever.
125
- replaced = installedVersion(ctx.paths) ?? release.version;
126
- lines.push(` ${ctx.ui.success(ctx.ui.bold(ctx.t.update.replaced(replaced)))}`);
142
+ outcome = "approval-required";
143
+ lines.push(...receiptLines(ctx.t.update.needsApproval, ctx.ui.width - 2).map(line => ` ${line}`));
127
144
  }
128
145
  else {
129
- lines.push(` ${ctx.t.update.declined}`);
146
+ lines.push(` ${ctx.t.update.wouldReplace(humanText(ctx.paths.lib), humanText(release.version))}`);
147
+ preview();
148
+ if (ctx.flags.auto || (await confirm(ctx.t.update.question(release.version))) === "yes") {
149
+ const tarball = await downloadRelease(release, ctx.t);
150
+ lines.push(` ${ctx.ui.accent(ctx.t.update.found(release.version, tarball.byteLength))}`);
151
+ installRelease(ctx.paths, tarball, ctx.t);
152
+ installed = installedVersion(ctx.paths);
153
+ if (installed === null)
154
+ throw new ConfigError(ctx.t.update.versionUnknown);
155
+ replaced = installed;
156
+ outcome = "updated";
157
+ lines.push(` ${ctx.ui.success(ctx.ui.bold(ctx.t.update.replaced(installed)))}`);
158
+ }
159
+ else {
160
+ outcome = "declined";
161
+ lines.push(` ${ctx.t.update.declined}`);
162
+ }
130
163
  }
131
164
  }
132
165
  const warnings = [];
@@ -134,20 +167,33 @@ export async function runUpdate(ctx) {
134
167
  // `seedFiles` are constants in the copy that has just been replaced, so a release that moved
135
168
  // the anchors would be reported against the old ones — which is the exact gap this command
136
169
  // exists to close. Say the one thing that is still true and stop measuring.
137
- if (replaced !== null || config === null || config.seedVersion === 0 || stateIssue !== null || (stale && stale.length > 0)) {
138
- lines.push("", ` ${ctx.t.update.thenRefresh}`, ` ${ctx.ui.accent("tonedial doctor --fix")}`);
139
- }
140
- if (replaced === null && config?.seedVersion === 0) {
141
- warnings.push(ctx.t.update.neverInitialised);
142
- }
143
- else if (replaced === null && stale && stale.length > 0) {
144
- // Second, and phrased as a second step: replacing the package is not the whole update, and
145
- // a user who stops at the first line is the one who ends up running new code on old anchors.
146
- for (const { seed, state } of stale) {
147
- if (state !== "current")
148
- warnings.push(`${ctx.t.doctor.seedReason[state]}\n${seed.path}`);
170
+ const inspectable = replaced === null ? stale ?? [] : [];
171
+ const routine = inspectable.filter(({ state }) => state === "missing" || state === "stale");
172
+ const problems = inspectable.filter(({ state }) => state !== "missing" && state !== "stale");
173
+ const fresh = stateIssue === "missing" && ![ctx.paths.agents, ctx.paths.tone, ctx.paths.presets, ctx.paths.skill].some(path => {
174
+ try {
175
+ return lstatSync(path, { throwIfNoEntry: false }) !== undefined;
176
+ }
177
+ catch {
178
+ return true;
179
+ }
180
+ });
181
+ if (stateIssue !== null && !fresh)
182
+ warnings.push(ctx.t.update.stateIssue[stateIssue]);
183
+ if (routine.length)
184
+ lines.push("", ` ${ctx.t.update.refreshFiles(routine.length)}`);
185
+ if (problems.length) {
186
+ lines.push("", ` ${ctx.t.wiring.stateRoot}`, ` ${humanText(ctx.paths.root)}`);
187
+ for (const reason of ["modified", "foreign", "unreadable"]) {
188
+ const group = problems.filter(({ state }) => state === reason);
189
+ if (group.length)
190
+ warnings.push([ctx.t.doctor.seedReason[reason], ...group.map(({ seed }) => seed.label)].join("\n"));
149
191
  }
150
192
  }
193
+ const stateRecoveryRequired = !fresh && (stateIssue !== null || config?.seedVersion === 0 || inspectable.length > 0 || replaced !== null);
194
+ const recoveryCommand = fresh ? "tonedial init" : problems.length ? "tonedial doctor" : "tonedial doctor --fix";
195
+ if (fresh || stateRecoveryRequired)
196
+ lines.push("", ` ${fresh ? ctx.t.update.setupNeeded : problems.length ? ctx.t.update.reviewState : ctx.t.update.thenRefresh}`, ` ${ctx.ui.accent(recoveryCommand)}`);
151
197
  return {
152
198
  lines,
153
199
  json: {
@@ -155,17 +201,18 @@ export async function runUpdate(ctx) {
155
201
  shape,
156
202
  command,
157
203
  replaced,
204
+ installedVersion: installed,
205
+ outcome,
158
206
  seedVersion: config?.seedVersion ?? null,
159
207
  stateIssue,
160
- stateRecoveryRequired: stateIssue !== null || config?.seedVersion === 0 || (stale?.length ?? 0) > 0 || replaced !== null,
161
- recoveryCommand: "tonedial doctor --fix",
208
+ stateRecoveryRequired,
209
+ recoveryCommand,
162
210
  // Null after a swap: this process is the old package, and reporting its constants as the
163
211
  // installed ones would be a claim it cannot make.
164
212
  packageSeedVersion: replaced === null ? SEED_VERSION : null,
165
213
  staleSeeds: replaced === null ? (stale?.map(({ seed }) => seed.label) ?? null) : null,
166
214
  seedIssues: replaced === null ? (stale?.map(({ seed, state }) => ({ path: seed.path, reason: state })) ?? null) : null,
167
- // Named for what it is. On npm this command asks nothing of the network, so a consumer
168
- // must not read silence here as "you are up to date".
215
+ // npm owns registry/cache resolution; only the curl branch observes remote metadata.
169
216
  checkedRemote: shape === "curl",
170
217
  },
171
218
  warnings,
@@ -65,7 +65,7 @@ export async function runUse(ctx, args) {
65
65
  risk: ctx.t.notice.risk,
66
66
  roleplay: config.axes.playfulness >= 4 ? ctx.t.notice.roleplay : null,
67
67
  };
68
- const notices = [notice.safeword, ...(notice.riskAxes ? [notice.riskAxes] : []), notice.risk];
68
+ const notices = [notice.safeword, ...(notice.riskAxes ? [notice.riskAxes] : [])];
69
69
  const lines = [
70
70
  ...wrap(ctx.t.preset.active(truncate(humanText(preset.displayName), ui.width - 12)), ui.width - 2)
71
71
  .map((line) => ` ${ui.success(ui.bold(line))}`),
@@ -74,7 +74,7 @@ export async function runUse(ctx, args) {
74
74
  // Folded here rather than at the printer: `lines` are laid out by the command that built
75
75
  // them, and a printer that reflowed them would break every column in the gauge block.
76
76
  "",
77
- ...notices.flatMap((n) => wrap(humanText(n), ui.width - 2).map((folded) => ` ${n === notice.riskAxes ? ui.risk(folded) : n === notice.risk ? ui.warn(folded) : folded}`)),
77
+ ...notices.flatMap((n) => wrap(humanText(n), ui.width - 2).map((folded) => ` ${n === notice.riskAxes ? ui.risk(folded) : folded}`)),
78
78
  ...project,
79
79
  ...nextStep("tonedial show", ui),
80
80
  ];
@@ -93,8 +93,7 @@ export async function runUse(ctx, args) {
93
93
  project: projectPath,
94
94
  },
95
95
  warnings: [
96
- ...(notice.roleplay ? [notice.roleplay] : []),
97
- ...checkCombos(config.axes, config.persona, config.speech_level).map((w) => combination(w, ctx.t)),
96
+ ...checkCombos(config.axes).map((w) => combination(w, ctx.t)),
98
97
  ],
99
98
  };
100
99
  }
@@ -453,7 +453,7 @@ function summary(ctx, reason, tools, action = "connect") {
453
453
  return receiptLines(action === "disconnect" ? ctx.t.wiring.alreadyDisconnected(named) : ctx.t.wiring.alreadyMatch(named), ctx.ui.width - 2)
454
454
  .map((line) => ` ${ctx.ui.bold(line)}`);
455
455
  if (reason === "dry-run")
456
- return [` ${ctx.ui.warn(ctx.t.wiring.dryRun)}`];
456
+ return [` ${ctx.ui.dim(ctx.t.wiring.dryRun)}`];
457
457
  if (reason === "declined")
458
458
  return [` ${ctx.t.wiring.declined}`];
459
459
  return [
@@ -164,6 +164,7 @@ export const SPEECH_LEVEL = {
164
164
  auto: "In languages with honorifics, choose address forms for the current relationship and scene; keep them consistent within each reply.",
165
165
  polite: "In languages with honorifics, use polite address forms even with familiar wording.",
166
166
  plain: "In languages with honorifics, use plain address forms even with formal wording.",
167
+ rough: "In languages with honorifics, use rough, cheeky non-honorific sentence endings; keep the configured address terms and the selected formality, warmth, profanity and playfulness levels.",
167
168
  };
168
169
  export const CONTROLS = {
169
170
  /**
@@ -30,45 +30,13 @@ const COMPOSITES = [
30
30
  export function composites(axes) {
31
31
  return COMPOSITES.filter((c) => c.fires(axes)).map((c) => c.line);
32
32
  }
33
- // Address heuristics intentionally cover Korean only; other free-form terms stay unclassified.
34
- // Widen only with a per-language address lexicon.
35
- const INFORMAL_SELF = /^(나|내|형|누나|오빠|언니)$/;
36
- const DEFERENTIAL_USER = /(님)$/;
37
- export function check(axes, persona, speechLevel) {
38
- const w = [];
39
- const add = (layer, rule, id) => w.push({ layer, rule, id });
40
- if (axes.refusal >= 4 && axes.compliance >= 4) {
41
- add("forbidden", "refusal≥4 × compliance≥4", "refusalCompliance");
42
- }
43
- if (persona) {
44
- const selfRef = persona.address?.self_ref ?? null;
45
- const userRef = persona.address?.user_ref ?? null;
46
- if (axes.formality === 5 && selfRef && INFORMAL_SELF.test(selfRef)) {
47
- add("awkward", "formality 5 × informal self_ref", "formalSelfRef");
48
- }
49
- if (axes.formality <= 2 && speechLevel === "plain" && userRef && DEFERENTIAL_USER.test(userRef)) {
50
- add("awkward", "formality≤2 × deferential user_ref", "casualUserRef");
51
- }
52
- if (persona.age === "teens" && axes.formality === 5) {
53
- add("awkward", "age: teens × formality 5", "teensFormal");
54
- }
55
- if (persona.age === "50s+" && selfRef && INFORMAL_SELF.test(selfRef)) {
56
- add("awkward", "age: 50s+ with a junior self_ref", "seniorJuniorSelfRef");
57
- }
58
- // The spec's eighth awkward rule — `age` contradicting the relative age implied by
59
- // `address` — is **not implemented**. It needs a kinship lexicon that maps 형 or 누나 to a
60
- // relative age, in a design that takes address terms as free-form text in any language.
61
- // Recorded rather than dropped: the rule is right and the input it needs does not exist.
62
- // Measured, not predicted: across five `terse-operator` runs the configured address never
63
- // surfaced once, because a maximally terse reply has no natural slot for a vocative.
64
- // The user is told rather than obeyed, since the alternative is forcing unnatural output.
65
- if ((selfRef || userRef) && (axes.warmth <= 2 || axes.verbosity <= 2)) {
66
- add("ineffective", "address set while warmth≤2 or verbosity≤2", "addressUnlikely");
67
- }
68
- }
69
- return w;
33
+ /** Independent persona choices are valid; warn only when axis instructions conflict. */
34
+ export function check(axes) {
35
+ return axes.refusal >= 4 && axes.compliance >= 4
36
+ ? [{ layer: "forbidden", rule: "refusal≥4 × compliance≥4", id: "refusalCompliance" }]
37
+ : [];
70
38
  }
71
- /** The axes a preset moves off their default. Sparsity is 5±2 of these. */
39
+ /** The axes a preset moves off their default; custom profiles have no count restriction. */
72
40
  export function nonDefaultAxes(axes, defaults) {
73
41
  // Walks AXIS_ORDER rather than Object.keys: map iteration order follows the JSON file, and a
74
42
  // hand-edited config carrying a stray key would otherwise be reported as a non-default axis.
@@ -35,8 +35,8 @@ function validate(config) {
35
35
  throw new RangeError(`axis ${axis} is ${level}, not a level 1 to 5`);
36
36
  }
37
37
  }
38
- if (!(config.speech_level in SPEECH_LEVEL)) {
39
- throw new RangeError(`speech_level is ${config.speech_level}, not auto, polite, or plain`);
38
+ if (!Object.hasOwn(SPEECH_LEVEL, config.speech_level)) {
39
+ throw new RangeError(`speech_level is ${config.speech_level}, not auto, polite, plain, or rough`);
40
40
  }
41
41
  if (config.persona !== null) {
42
42
  for (const field of ["user", "agent"]) {
@@ -10,8 +10,7 @@ export const CONFIG_SCHEMA = 2;
10
10
  export const LEGACY_CONFIG_SCHEMA = 1;
11
11
  export const AGE_BANDS = ["unspecified", "teens", "20s", "30s", "40s", "50s+"];
12
12
  export const GENDERS = ["unspecified", "masculine", "feminine", "neutral"];
13
- export const SPEECH_LEVELS = ["auto", "polite", "plain"];
14
- export const NOTE_MAX = 200;
13
+ export const SPEECH_LEVELS = ["auto", "polite", "plain", "rough"];
15
14
  export const WIRING_SCOPES = ["global", "project"];
16
15
  export const RESOURCE_KINDS = ["hook", "skill", "shell", "reader", "command"];
17
16
  export const RESOURCE_TYPES = ["file", "directory", "symlink"];
@@ -854,20 +853,32 @@ export const isAxis = (name) => AXIS_ORDER.includes(name);
854
853
  /**
855
854
  * Everything `set <key>=<value>` takes that is not an axis.
856
855
  *
857
- * Two consumers: the did-you-mean match in `set`, and the order `show --explain` renders the
858
- * per-language descriptions in. It does not drive `assign` or `readKey` — each of those branches
859
- * carries its own parsing and its own refusal.
856
+ * Shared by key discovery, setting reads, and the did-you-mean match in `set`.
857
+ * Assignment retains its own validation.
860
858
  *
861
- * Only one drift direction is caught. A key listed here with no branch in `assign` fails the
862
- * test that drives every entry through `set`. A branch added to `assign` and never listed here
863
- * is invisible: it would not appear in `--explain` and `nearest` would not suggest it, and
864
- * nothing fails. Closing that needs `assign` to be a table rather than a chain of `if`s.
859
+ * Command tests exercise this inventory through both setting and reading values.
865
860
  */
866
861
  export const SETTING_KEYS = [
867
862
  "safeword", "speech_level", "language",
868
863
  "persona.age", "persona.gender", "persona.self_ref", "persona.user_ref", "persona.note",
869
864
  "persona.user", "persona.agent",
870
865
  ];
866
+ /** Current editable values, without presentation labels or internal ownership metadata. */
867
+ export function settingValues(config) {
868
+ return {
869
+ ...Object.fromEntries(AXIS_ORDER.map(axis => [axis, config.axes[axis]])),
870
+ safeword: config.safeword,
871
+ speech_level: config.speech_level,
872
+ language: config.language,
873
+ "persona.age": config.persona.age,
874
+ "persona.gender": config.persona.gender,
875
+ "persona.self_ref": config.persona.address?.self_ref ?? null,
876
+ "persona.user_ref": config.persona.address?.user_ref ?? null,
877
+ "persona.note": config.persona.note,
878
+ "persona.user": config.persona.user,
879
+ "persona.agent": config.persona.agent,
880
+ };
881
+ }
871
882
  /** Levenshtein-lite: enough to say "did you mean critique?" and no more. */
872
883
  export function nearest(name, candidates) {
873
884
  const distance = (a, b) => {