tonedial 0.1.5 → 0.1.7

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/README.zh.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **给编程代理一个角色。**
4
4
 
5
- 选择角色,调整十二个语气轴,保存一套能在不同项目中复用的语气。
5
+ ToneDial 是本地 CLI,用于调整编程代理的语气并在不同项目中复用。
6
6
 
7
7
  **7 个角色 · 12 个语气轴 · 5 种 CLI 语言 · 零运行时依赖**
8
8
 
@@ -19,9 +19,36 @@
19
19
 
20
20
  **[查看全部 7 个角色 →](https://github.com/awesometic/tonedial/blob/main/docs/EXAMPLES.zh.md)**
21
21
 
22
- ## 试试这样用?
22
+ ## 同一模型,更精简的计划
23
23
 
24
- 完成[设置](#开始试用)后,选择一组配置。
24
+ 在 `gpt-5.6-sol` / `high` 下,一个全新会话使用 Codex 默认设置,另一个使用下方完整的 `vibe-coder` 配置。
25
+ `verbosity=1` 只是该配置的一部分,并非唯一差异。
26
+
27
+ **指令**
28
+
29
+ > I want to make ToneDial easier for first-time users. The README should get them from installation through `tonedial init` to their first preview with `tonedial test codex --preset <slug>`, and the CLI should make that next step obvious after `init`. Give me a prioritized three-step improvement plan for the README and first-run CLI flow.
30
+
31
+ <img src="https://raw.githubusercontent.com/awesometic/tonedial/main/docs/media/readme/zh/verbosity-comparison.svg" alt="GPT-5.6 Sol high 使用 Codex 默认设置和完整 vibe-coder 配置回答同一条 ToneDial 改进计划指令;配置后的英文回复从 148 个词缩短到 82 个词" width="760">
32
+
33
+ <sub>同一指令 · 每个新会话的首次完整回复 · 英文回复 148 个词 → 82 个词。这是一次示例性对比,并非只隔离单个轴的基准测试。回复用于展示语气差异;产品行为和命令请以下方指南为准。 <a href="https://github.com/awesometic/tonedial/blob/main/docs/media/readme/verbosity-comparison.json">输入与生成记录</a>.</sub>
34
+
35
+ ## 开始试用
36
+
37
+ 需要 **Node 24 或更高版本**和 npm。
38
+
39
+ ```bash
40
+ npm install --global tonedial
41
+ tonedial init --tools none --global --lang zh
42
+ tonedial set
43
+ ```
44
+
45
+ 设置时会先展示文件变更并请求批准。无需连接代理即可先打开面板试用。
46
+
47
+ [连接代理](https://github.com/awesometic/tonedial/blob/main/docs/USAGE.md#connecting-an-agent) · [使用临时设置试用](https://github.com/awesometic/tonedial/blob/main/docs/TESTING.md#isolated-candidate)
48
+
49
+ ## 复制一种语气
50
+
51
+ 复制完整配置,或把它作为自定义设置的起点。
25
52
  每条命令都会保存并应用完整配置;`--auto` 会批准显示的更改。
26
53
 
27
54
  实际 `tonedial test codex` 回复 · `gpt-6.1-sol` / `high` · 每组配置保留问题的首次回答 · 仅对话,不编辑文件。 [输入与生成记录](https://github.com/awesometic/tonedial/blob/main/docs/media/readme/zh/use-cases.json).
@@ -35,7 +62,7 @@
35
62
  话少、直接选定推荐方案,简短汇报做完了什么、还剩什么的编程搭档。
36
63
 
37
64
  <details>
38
- <summary>复制这套配置</summary>
65
+ <summary>配置与实际回复</summary>
39
66
 
40
67
  ```bash
41
68
  tonedial create '彻底放手的氛围编程' --slug vibe-coder --auto \
@@ -51,8 +78,6 @@ tonedial create '彻底放手的氛围编程' --slug vibe-coder --auto \
51
78
 
52
79
  试试这个语气: `tonedial test codex --preset vibe-coder`
53
80
 
54
- </details>
55
-
56
81
  **Codex**
57
82
 
58
83
  > 老板,我选把 `null` 和 `undefined` 转为空字符串,避免 `trim()` 报错:
@@ -63,13 +88,14 @@ tonedial create '彻底放手的氛围编程' --slug vibe-coder --auto \
63
88
  > }
64
89
  > ```
65
90
 
91
+ </details>
66
92
 
67
93
  ### 需要安慰的时候
68
94
 
69
95
  温柔、爱夸人,偶尔说几句安慰的话,像个让人安心的搭档。
70
96
 
71
97
  <details>
72
- <summary>复制这套配置</summary>
98
+ <summary>配置与实际回复</summary>
73
99
 
74
100
  ```bash
75
101
  tonedial create '需要安慰的时候' --slug steady-companion --auto \
@@ -85,8 +111,6 @@ tonedial create '需要安慰的时候' --slug steady-companion --auto \
85
111
 
86
112
  试试这个语气: `tonedial test codex --preset steady-companion`
87
113
 
88
- </details>
89
-
90
114
  **Codex**
91
115
 
92
116
  > 我的选择:搭档,把 `null` 和 `undefined` 当作空字符串,其他输入保留原来的处理方式。
@@ -101,13 +125,14 @@ tonedial create '需要安慰的时候' --slug steady-companion --auto \
101
125
  >
102
126
  > 你贴出的函数很清楚,指出触发条件很细致,聚焦一个方案很明智,要求直接替换的版本也很周到。辛苦了,我陪你拆问题,慢慢来,放心先喘口气——这个空值小坑已经有块补丁了。
103
127
 
128
+ </details>
104
129
 
105
130
  ### 就喜欢嘴狠的
106
131
 
107
132
  脏话照说,嘲讽照给,活也照样干利索的狠角色。
108
133
 
109
134
  <details>
110
- <summary>复制这套配置</summary>
135
+ <summary>配置与实际回复</summary>
111
136
 
112
137
  ```bash
113
138
  tonedial create '就喜欢嘴狠的' --slug rough-but-reliable --auto \
@@ -123,8 +148,6 @@ tonedial create '就喜欢嘴狠的' --slug rough-but-reliable --auto \
123
148
 
124
149
  试试这个语气: `tonedial test codex --preset rough-but-reliable`
125
150
 
126
- </details>
127
-
128
151
  **Codex**
129
152
 
130
153
  > 喂,老子选空值返回空字符串这招,一行就把这他妈的崩溃点收拾利索:
@@ -137,23 +160,11 @@ tonedial create '就喜欢嘴狠的' --slug rough-but-reliable --auto \
137
160
  >
138
161
  > `null` 或 `undefined` 返回 `""`,`" Ada "` 返回 `"Ada"`。
139
162
 
163
+ </details>
164
+
140
165
  Codex 开始对话后,输入上面的问题即可。
141
166
  模型与推理强度由 Codex 设置,与 ToneDial 配置分开。
142
167
 
143
- ## 开始试用
144
-
145
- 需要 **Node 24 或更高版本**和 npm。
146
-
147
- ```bash
148
- npm install --global tonedial
149
- tonedial init --tools none --global --lang zh
150
- tonedial set
151
- ```
152
-
153
- 设置时会先展示文件变更并请求批准。无需连接代理即可先打开面板试用。
154
-
155
- [连接代理](https://github.com/awesometic/tonedial/blob/main/docs/USAGE.md#connecting-an-agent) · [使用临时设置试用](https://github.com/awesometic/tonedial/blob/main/docs/TESTING.md#isolated-candidate)
156
-
157
168
  ## 选择你的角色
158
169
 
159
170
  | 预设 | 你 → 代理 |
package/dist/cli.js CHANGED
@@ -30,24 +30,28 @@ import { runEject } from "./commands/eject.js";
30
30
  import { runInit } from "./commands/init.js";
31
31
  import { runUnwire, runWire } from "./commands/wire.js";
32
32
  import { runTest } from "./commands/test.js";
33
+ import { runOff, runOn } from "./commands/toggle.js";
33
34
  export const JSON_SCHEMA = 3;
34
35
  /**
35
36
  * Stated here rather than read from `package.json`, because reading a package-relative file at
36
37
  * run time is the one thing that breaks all three binary packagers at once. A test asserts this
37
38
  * equals the manifest, which is the cheap half of the trade.
38
39
  */
39
- export const VERSION = "0.1.5";
40
- const FLAG_NAMES = ["project", "check", "explain", "global", "here", "migrateLegacy", "auto", "dryRun", "fix", "rollback", "fromOperation", "fromPreset", "restorePreset", "preset", "slug", "acceptUsage", "lang", "commands", "tools"];
40
+ export const VERSION = "0.1.7";
41
+ const FLAG_NAMES = ["project", "check", "explain", "global", "here", "migrateLegacy", "auto", "dryRun", "fix", "rollback", "quarantineOperation", "fromOperation", "fromPreset", "restorePreset", "preset", "slug", "acceptUsage", "lang", "commands", "tools"];
41
42
  /** Command-line spelling for each flag, where it differs from the field name. */
42
43
  const FLAG_SPELLING = {
43
44
  project: "project", check: "check", explain: "explain",
44
45
  global: "global", auto: "auto", dryRun: "dry-run", fix: "fix", lang: "lang", commands: "commands",
45
46
  tools: "tools", rollback: "rollback", here: "here", migrateLegacy: "migrate-legacy",
47
+ quarantineOperation: "quarantine-operation",
46
48
  fromOperation: "from-operation", fromPreset: "from-preset", restorePreset: "restore-preset",
47
49
  preset: "preset", slug: "slug", acceptUsage: "accept-usage",
48
50
  };
49
51
  /** Each command declares its accepted flags and positional limit; null means unlimited. */
50
52
  const COMMANDS = {
53
+ on: { run: runOn, flags: [], max: 0, context: "state" },
54
+ off: { run: runOff, flags: [], max: 0, context: "state" },
51
55
  set: { run: runSet, flags: [], max: null, context: "state" },
52
56
  get: { run: runGet, flags: [], max: 1 },
53
57
  test: { run: runTest, flags: ["project", "dryRun", "preset", "acceptUsage"], max: 1 },
@@ -68,7 +72,7 @@ const COMMANDS = {
68
72
  unwire: { run: runUnwire, flags: ["global", "here", "project", "auto", "dryRun"], max: 1 },
69
73
  init: { run: runInit, flags: ["global", "here", "project", "migrateLegacy", "auto", "dryRun", "lang", "commands", "tools"], max: 0, context: "setup" },
70
74
  // Doctor checks mutation permission after its recovery-first diagnosis, even with bad config.
71
- doctor: { run: runDoctor, flags: ["global", "here", "project", "auto", "dryRun", "fix", "rollback", "fromOperation", "fromPreset", "restorePreset", "tools"], max: 0 },
75
+ doctor: { run: runDoctor, flags: ["global", "here", "project", "auto", "dryRun", "fix", "rollback", "quarantineOperation", "fromOperation", "fromPreset", "restorePreset", "tools"], max: 0 },
72
76
  eject: { run: runEject, flags: ["global", "here", "project", "auto", "dryRun"], max: 0 },
73
77
  };
74
78
  const CONNECTION_COMMANDS = ["init", "wire", "doctor", "unwire", "eject"];
@@ -80,6 +84,8 @@ export const COMMAND_NAMES = Object.keys(COMMANDS);
80
84
  /** Intent groups share one command inventory between compact and full help. */
81
85
  const COMMAND_GROUPS = [
82
86
  ["tone", [
87
+ ["on", "on"],
88
+ ["off", "off"],
83
89
  ["set", "set <key>=<value> …"],
84
90
  ["get", "get [key]"],
85
91
  ["address", "address [self user]"],
@@ -111,6 +117,8 @@ const COMMAND_GROUPS = [
111
117
  ];
112
118
  const COMMAND_SYNTAX = COMMAND_GROUPS.flatMap(([, rows]) => rows);
113
119
  const COMMAND_EXAMPLES = {
120
+ on: ["tonedial on"],
121
+ off: ["tonedial off"],
114
122
  set: ["tonedial set", "tonedial set warmth=5 critique=4", "tonedial set persona.agent=reviewer"],
115
123
  get: ["tonedial get", "tonedial get language", "tonedial get speech_level --json"],
116
124
  test: ["tonedial test codex --dry-run", "tonedial test claude --project .", "tonedial test codex --preset my-tone --accept-usage"],
@@ -130,7 +138,8 @@ const COMMAND_EXAMPLES = {
130
138
  wire: ["tonedial wire codex --here --dry-run"],
131
139
  unwire: ["tonedial unwire codex --here --dry-run"],
132
140
  init: ["tonedial init --tools none --global --auto", "tonedial init --tools codex --here"],
133
- doctor: ["tonedial doctor", "tonedial doctor --fix --global --tools none --dry-run"],
141
+ doctor: ["tonedial doctor", "tonedial doctor --fix --global --tools none --dry-run",
142
+ "tonedial doctor --fix --quarantine-operation ID --global"],
134
143
  eject: ["tonedial eject --here --dry-run"],
135
144
  };
136
145
  const FLAG_SYNTAX = [
@@ -145,6 +154,7 @@ const FLAG_SYNTAX = [
145
154
  ["tools", "--tools=LIST"],
146
155
  ["fix", "--fix"],
147
156
  ["rollback", "--rollback=ID"],
157
+ ["quarantineOperation", "--quarantine-operation=ID"],
148
158
  ["fromOperation", "--from-operation=ID"],
149
159
  ["fromPreset", "--from-preset=SLUG"],
150
160
  ["restorePreset", "--restore-preset=SLUG"],
@@ -271,16 +281,12 @@ function columns(env, stream, isTTY) {
271
281
  return isTTY ? stream.columns : undefined;
272
282
  }
273
283
  /**
274
- * Words a person reaches for when they want to stop, none of which is a command.
275
- *
276
- * There is no off switch to give them, so the answer names the acts that do exist and says so.
284
+ * Words a person reaches for when they want to stop, but which are not command spellings.
277
285
  *
278
286
  * A deliberate ceiling: the list is guessed, not measured — nobody has reported typing any of
279
- * them. Replace it with a measured one when a user reports a word that is not here, or drop it
280
- * if the answer turns out to be the list of seventeen after all. The cost of being wrong is one
281
- * string; the cost of being right is that `tonedial off` stops dead-ending in that list.
287
+ * them. Replace it with a measured one when a user reports a word that is not here.
282
288
  */
283
- const STOPPING = ["off", "disable", "stop", "quit", "uninstall", "remove", "delete"];
289
+ const STOPPING = ["disable", "stop", "quit", "uninstall", "remove", "delete"];
284
290
  export async function run(argv, env = process.env, cwd = process.cwd(), isTTY = process.stdout.isTTY === true, isErrTTY = process.stderr.isTTY === true) {
285
291
  const options = {
286
292
  json: { type: "boolean", default: false },
@@ -298,6 +304,7 @@ export async function run(argv, env = process.env, cwd = process.cwd(), isTTY =
298
304
  "dry-run": { type: "boolean", default: false },
299
305
  fix: { type: "boolean", default: false },
300
306
  rollback: { type: "string" },
307
+ "quarantine-operation": { type: "string" },
301
308
  "from-operation": { type: "string" },
302
309
  "from-preset": { type: "string" },
303
310
  "restore-preset": { type: "string" },
@@ -383,7 +390,7 @@ export async function run(argv, env = process.env, cwd = process.cwd(), isTTY =
383
390
  // one: `stop` is two edits from `show` and `delete` three from `reset`, so the match would
384
391
  // answer a person trying to leave with the command they were leaving.
385
392
  if (STOPPING.includes(name))
386
- return fail(asJson, say.errors.noOffSwitch, 2, uiErr);
393
+ return fail(asJson, say.errors.stoppingHelp, 2, uiErr);
387
394
  return fail(asJson, say.errors.notACommand(asJson ? name : humanText(name), COMMAND_NAMES, nearest(name, COMMAND_NAMES)), 2, uiErr);
388
395
  }
389
396
  const flags = {
@@ -398,6 +405,7 @@ export async function run(argv, env = process.env, cwd = process.cwd(), isTTY =
398
405
  dryRun: parsed.values["dry-run"] === true,
399
406
  fix: parsed.values.fix === true,
400
407
  rollback: typeof parsed.values.rollback === "string" ? parsed.values.rollback : null,
408
+ quarantineOperation: typeof parsed.values["quarantine-operation"] === "string" ? parsed.values["quarantine-operation"] : null,
401
409
  fromOperation: typeof parsed.values["from-operation"] === "string" ? parsed.values["from-operation"] : null,
402
410
  fromPreset: typeof parsed.values["from-preset"] === "string" ? parsed.values["from-preset"] : null,
403
411
  restorePreset: typeof parsed.values["restore-preset"] === "string" ? parsed.values["restore-preset"] : null,
@@ -424,9 +432,9 @@ export async function run(argv, env = process.env, cwd = process.cwd(), isTTY =
424
432
  if (command.max !== null && rest.length > command.max) {
425
433
  return fail(asJson, say.errors.tooManyArguments(name, command.max, rest.length), 2, uiErr);
426
434
  }
427
- if (name === "doctor" && ((!flags.fix && (flags.fromOperation !== null || flags.fromPreset !== null || flags.restorePreset !== null || flags.tools !== null)) ||
435
+ if (name === "doctor" && ((!flags.fix && (flags.quarantineOperation !== null || flags.fromOperation !== null || flags.fromPreset !== null || flags.restorePreset !== null || flags.tools !== null)) ||
428
436
  (flags.restorePreset !== null && flags.fromOperation === null) ||
429
- (flags.rollback !== null && (flags.fromOperation !== null || flags.fromPreset !== null || flags.restorePreset !== null || flags.tools !== null)))) {
437
+ (flags.rollback !== null && (flags.quarantineOperation !== null || flags.fromOperation !== null || flags.fromPreset !== null || flags.restorePreset !== null || flags.tools !== null)))) {
430
438
  return fail(asJson, say.recovery.invalidOptions, 2, uiErr);
431
439
  }
432
440
  try {
@@ -107,7 +107,7 @@ export async function runCompile(ctx) {
107
107
  ` ${ctx.ui.dim(artifactSize(size, ctx.t))}`,
108
108
  ...nextStep(check && !current ? "tonedial compile" : "tonedial show", ctx.ui),
109
109
  ],
110
- json: { path: ctx.paths.tone, agentsPath: ctx.paths.agents, written: !check, ...size },
110
+ json: { path: ctx.paths.tone, agentsPath: ctx.paths.agents, enabled: config.enabled, written: !check, ...size },
111
111
  warnings: [],
112
112
  };
113
113
  }
@@ -11,6 +11,7 @@ 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";
14
+ import { DELIVERY_OFF_MARKER } from "../core/anchors.js";
14
15
  import { inspect as inspectSkill, skillPath } from "../wiring/skill.js";
15
16
  import { candidateRcPaths, pathLineState, rcPath, withPathLine } from "../wiring/shell.js";
16
17
  import { SEED_VERSION, inspectContextLayout, inspectSeeds, recordedSeedSelection, } from "../core/seeds.js";
@@ -94,7 +95,9 @@ function readerHealth(ctx, config, configRead, seeds, pairRequired) {
94
95
  temporary = mkdtempSync(join(tmpdir(), "tonedial-reader-check-"));
95
96
  temporary = realpathSync(temporary);
96
97
  const guide = `${GUIDE_FORMAT_MARKER}\nToneDial diagnostic guide.\n`;
97
- const artifact = "ToneDial diagnostic tone — 한글 🌿.\n";
98
+ const artifact = config.enabled
99
+ ? "ToneDial diagnostic tone — 한글 🌿.\n"
100
+ : `<!-- generated by tonedial · preset: unsaved -->\n<!-- tonedial-style:1 verbosity=3 care=3 -->\n${DELIVERY_OFF_MARKER}\nToneDial diagnostic tone — 한글 🌿.\n`;
98
101
  writeFileSync(join(temporary, CONTEXT_GUIDE_FILENAME), guide, { flag: "wx", mode: 0o600 });
99
102
  writeFileSync(join(temporary, CONTEXT_ARTIFACT_FILENAME), artifact, { flag: "wx", mode: 0o600 });
100
103
  const body = guide + "\n\n" + artifact;
@@ -110,7 +113,8 @@ function readerHealth(ctx, config, configRead, seeds, pairRequired) {
110
113
  maxBuffer: 8192,
111
114
  });
112
115
  const expected = provider === "antigravity"
113
- ? JSON.stringify({ injectSteps: [{ ephemeralMessage: body }] }) + "\n" : body;
116
+ ? JSON.stringify({ injectSteps: config.enabled ? [{ ephemeralMessage: body }] : [] }) + "\n"
117
+ : config.enabled ? body : "";
114
118
  if (probe.error || probe.status !== 0 || probe.stderr !== "" || probe.stdout !== expected)
115
119
  return result;
116
120
  }
@@ -472,6 +476,20 @@ export async function runDoctor(ctx, _args) {
472
476
  if (!ctx.flags.fix)
473
477
  return doctorReport(ctx);
474
478
  const app = await runAppRebuild(ctx);
479
+ if (app.quarantineOnly) {
480
+ const rollbackLines = app.json.applied === true ? recoveryRollbackLines(ctx.t, app.json.operationId, null) : [];
481
+ const remaining = Array.isArray(app.json.remainingInvalidOperations)
482
+ ? app.json.remainingInvalidOperations.filter((id) => typeof id === "string") : [];
483
+ const next = typeof app.json.next === "string" ? app.json.next : null;
484
+ return {
485
+ ...app,
486
+ lines: [...app.lines, ...rollbackLines, ...(next === null ? [] : [
487
+ ` ${ctx.t.recovery.quarantineRemaining(remaining.length)}`, "", ` ${next}`,
488
+ ])],
489
+ json: { ...app.json, root: ctx.agent.root, stateRoot: ctx.paths.root, agentRoot: ctx.agent.root,
490
+ live: ctx.agent.live, fixed: app.json.applied === true, appHealthy: false },
491
+ };
492
+ }
475
493
  if (ctx.flags.dryRun) {
476
494
  const native = await runRebuildNative(ctx, app.legacy, app.proposedConfig);
477
495
  return {
@@ -517,10 +535,14 @@ async function doctorReport(ctx, showRollbackCommands = true, showCodexAction =
517
535
  if (inspected.lock.state !== "none" || inspected.journals.some(({ state }) => state === "pending" || state === "invalid")) {
518
536
  const owner = inspected.lock.owner;
519
537
  const blocking = inspected.journals.filter(({ state }) => state === "pending" || state === "invalid");
538
+ const invalid = blocking.filter(({ state }) => state === "invalid");
539
+ const quarantineable = inspected.lock.state === "none" && blocking.length === invalid.length && invalid.length > 0;
540
+ const singleQuarantine = quarantineable && invalid.length === 1;
520
541
  const shown = ctx.flags.verbose ? inspected.journals : inspected.journals.filter(({ operationId, state }) => state === "pending" || state === "invalid" || operationId === owner?.operationId);
521
542
  const lines = [
522
- ...wrap(ctx.t.doctor.recoveryPending, ctx.ui.width - 2).map(line => ` ${ctx.ui.warn(line)}`),
523
- ...shown.map(({ operationId, state }) => ` ${operationId} ${ctx.t.doctor.operationState[state]}`),
543
+ ...wrap(quarantineable ? ctx.t.doctor.invalidRecoveryPending : ctx.t.doctor.recoveryPending, ctx.ui.width - 2).map(line => ` ${ctx.ui.warn(line)}`),
544
+ ...shown.filter(({ operationId }) => !singleQuarantine || operationId !== invalid[0].operationId)
545
+ .map(({ operationId, state }) => ` ${operationId} ${ctx.t.doctor.operationState[state]}`),
524
546
  ];
525
547
  if (owner !== null && !shown.some(({ operationId }) => operationId === owner.operationId))
526
548
  lines.push(` ${owner.operationId}`);
@@ -540,7 +562,14 @@ async function doctorReport(ctx, showRollbackCommands = true, showCodexAction =
540
562
  }
541
563
  }
542
564
  }
543
- lines.push(...nextStep(`${repairCommand(ctx, "doctor")} --json`, ctx.ui));
565
+ if (quarantineable) {
566
+ lines.push("", ...(singleQuarantine
567
+ ? wrap(ctx.t.recovery.quarantineCandidate(invalid[0].operationId), ctx.ui.width - 2)
568
+ : wrap(ctx.t.recovery.quarantineRemaining(invalid.length), ctx.ui.width - 2)).map(line => ` ${line}`), ...nextStep(repairCommand(ctx, `doctor --fix --quarantine-operation ${invalid[0].operationId}`), ctx.ui).slice(1));
569
+ }
570
+ else {
571
+ lines.push(...nextStep(`${repairCommand(ctx, "doctor")} --json`, ctx.ui));
572
+ }
544
573
  return {
545
574
  lines,
546
575
  // The damaged config is deliberately not parsed here. `operation` is the only safe context
@@ -33,9 +33,11 @@ export function runGet(ctx, args) {
33
33
  return [` ${ctx.ui.accent(padEnd(key, column))} ${first ?? ""}`,
34
34
  ...rest.map(line => `${" ".repeat(column + 4)}${line}`)];
35
35
  });
36
+ if (requested === undefined && !config.enabled)
37
+ lines.unshift(` ${ctx.ui.bold(ctx.t.delivery.status(false))}`, "");
36
38
  if (requested !== undefined) {
37
39
  const copy = catalogue(isLanguage(config.language) ? config.language : detectLanguage(ctx.env));
38
40
  lines.push("", ...settingHelp(requested, copy).flatMap(hint => wrap(hint, ctx.ui.width - 2).map(line => ` ${ctx.ui.dim(line)}`)));
39
41
  }
40
- return { lines, json: { values } };
42
+ return { lines, json: { enabled: config.enabled, values } };
41
43
  }
@@ -297,6 +297,9 @@ async function rebuildExisting(entry) {
297
297
  const resultHeading = ctx.canPrompt && !ctx.flags.auto && !ctx.flags.dryRun
298
298
  ? section(copy.init.resultHeading, ctx.ui) : [];
299
299
  const app = await runAppRebuild(ctx);
300
+ if (app.quarantineOnly) {
301
+ return { ...app, lines: [...(ctx.canPrompt ? [] : introduction), ...resultHeading, ...app.lines] };
302
+ }
300
303
  if (ctx.flags.dryRun) {
301
304
  const answers = await ask(ctx, copy, false, selection);
302
305
  const tools = selection ?? answers.tools;
@@ -11,14 +11,14 @@ import { recoverLegacyTone } from "../core/legacy-tone.js";
11
11
  import { connectionScopeFlag, exportAliasesState, stateOverlapsInstructions } from "../core/paths.js";
12
12
  import { applyPreset, matchesPreset, assertPresetSelection, BUNDLED, customPresetDir, inspectPresetLibrary, parsePreset, PRESET_MAX_BYTES, resolvePreset, serializePreset, SLUG_PATTERN, } from "../core/presets.js";
13
13
  import { contextArtifactTargets, generatedSeedSelection, planGeneratedSeedRebuild, seedFiles, SEED_VERSION, } from "../core/seeds.js";
14
- import { inspectOperations, readJournal, retireOperationForAppRebuild, } from "../core/operations.js";
14
+ import { inspectOperations, readJournal, retireOperationForAppRebuild, serializeOperationQuarantine, } from "../core/operations.js";
15
15
  import { assertFresh, captureRawEntry, gate, planEntryRebuild, rawReadCheck, validateFileAction, } from "../wiring/apply.js";
16
16
  import { confirm } from "../ui/prompt.js";
17
17
  import { wrap } from "../ui/width.js";
18
18
  import { parseToolSelection } from "../wiring/targets.js";
19
19
  import { riskWarnings } from "../ui/format.js";
20
20
  const CONFIG_MAX_BYTES = 1024 * 1024;
21
- const scalarFields = ["preset", "unsaved", "safeword", "speech_level", "language"];
21
+ const scalarFields = ["enabled", "preset", "unsaved", "safeword", "speech_level", "language"];
22
22
  const personaFields = ["age", "gender", "note", "user", "agent"];
23
23
  function capturedText(capture) {
24
24
  if (capture.type !== "regular" || capture.bytes === null)
@@ -218,13 +218,83 @@ function privateCapture(capture) {
218
218
  boundary: capture.boundary, boundaryIdentity: capture.boundaryIdentity,
219
219
  parents: capture.parents.map(parent => ({ ...parent })) };
220
220
  }
221
+ function planOperationQuarantine(ctx, entry) {
222
+ const capture = captureRawEntry(entry.path, ctx.t, { within: ctx.paths.root });
223
+ if (capture.type !== "regular" || capture.bytes === null)
224
+ throw new ConfigError(ctx.t.recovery.quarantineUnavailable(entry.operationId));
225
+ return planEntryRebuild(capture, serializeOperationQuarantine(ctx.paths, entry.operationId, capture.bytes), ctx.t, { reason: "quarantine-operation", mode: capture.mode ?? 0o600 }).action;
226
+ }
227
+ async function quarantineOneOperation(ctx, entry, remaining) {
228
+ const action = planOperationQuarantine(ctx, entry);
229
+ const presentation = [ctx.t.recovery.quarantine(entry.operationId)]
230
+ .flatMap(line => wrap(line, ctx.ui.width - 2).map(part => ` ${part}`));
231
+ const result = await gate([], {
232
+ paths: ctx.paths, operationActions: [action], retention: { kind: "migration", reason: "app-rebuild" },
233
+ auto: ctx.flags.auto, dryRun: ctx.flags.dryRun, verbose: ctx.flags.verbose, ui: ctx.ui, t: ctx.t,
234
+ heading: ctx.t.recovery.heading, interactive: () => ctx.canPrompt, ...(ctx.canPrompt ? {} : { out: () => { } }),
235
+ recoveryPresentation: { lines: presentation,
236
+ failureMessage: (id, phase, path, code) => `${ctx.t.recovery.failure(id, phase, path, code)}\ntonedial doctor ${connectionScopeFlag(ctx.agent)} --json` },
237
+ verify: () => {
238
+ const state = inspectOperations(ctx.paths).journals.find(({ operationId }) => operationId === entry.operationId)?.state;
239
+ if (state !== "quarantined")
240
+ throw new ConfigError(ctx.t.recovery.sourceInvalid);
241
+ },
242
+ });
243
+ const lines = [...result.lines];
244
+ if (result.reason === "written") {
245
+ lines.push(` ${ctx.t.recovery.quarantineComplete(entry.operationId)}`);
246
+ }
247
+ else {
248
+ lines.push(` ${result.reason === "dry-run" ? ctx.t.wiring.dryRun : ctx.t.wiring.declined}`);
249
+ }
250
+ return {
251
+ lines,
252
+ json: {
253
+ appRebuilt: false, applied: result.applied, reason: result.reason, operationId: result.operationId ?? null,
254
+ root: ctx.paths.root, quarantinedOperation: entry.operationId, remainingInvalidOperations: [...remaining],
255
+ next: result.applied && remaining.length > 0
256
+ ? `tonedial doctor --fix --quarantine-operation ${remaining[0]} ${connectionScopeFlag(ctx.agent)}` : null,
257
+ nativeAttention: false, activationVerified: false, diff: result.diff,
258
+ },
259
+ rebuiltConfig: null,
260
+ proposedConfig: null,
261
+ legacy: null,
262
+ quarantineOnly: true,
263
+ };
264
+ }
221
265
  export async function runAppRebuild(ctx) {
222
266
  const t = ctx.t;
223
267
  const operations = inspectOperations(ctx.paths);
224
- const pending = operations.journals.filter(entry => entry.state === "pending" || entry.state === "invalid");
225
- const blocked = operations.lock.state !== "none" || pending.length > 0;
226
- if (blocked && (operations.lock.state === "live" || operations.lock.state === "unknown" ||
227
- pending.some(entry => entry.journal === null) || (!ctx.flags.fromOperation && (ctx.flags.auto || !ctx.canPrompt)))) {
268
+ const pending = operations.journals.filter(entry => entry.state === "pending");
269
+ const invalid = operations.journals.filter(entry => entry.state === "invalid");
270
+ let quarantined = invalid.find(entry => entry.operationId === ctx.flags.quarantineOperation) ?? null;
271
+ if (ctx.flags.quarantineOperation && quarantined === null)
272
+ throw new ConfigError(t.recovery.quarantineUnavailable(ctx.flags.quarantineOperation));
273
+ if (invalid.length > 0 && quarantined === null && operations.lock.state === "none" && pending.length === 0 && invalid.length === 1 &&
274
+ !ctx.flags.auto && ctx.canPrompt) {
275
+ const candidate = invalid[0];
276
+ const input = createInterface({ input: process.stdin, output: process.stdout });
277
+ try {
278
+ const notice = t.recovery.quarantineCandidate(candidate.operationId).split("\n").flatMap(line => line === candidate.operationId ? [line] : wrap(line, ctx.ui.width - 2));
279
+ process.stdout.write(`${notice.map(line => ` ${line}`).join("\n")}\n`);
280
+ const question = wrap(t.recovery.quarantineQuestion, ctx.ui.width - 3).map(line => ` ${line}`).join("\n");
281
+ const answer = (await input.question(`${question} `)).trim();
282
+ if (answer === "")
283
+ throw new ConfigError(t.recovery.cancelled);
284
+ if (answer !== candidate.operationId)
285
+ throw new ConfigError(t.recovery.operationMismatch);
286
+ quarantined = candidate;
287
+ }
288
+ finally {
289
+ input.close();
290
+ }
291
+ }
292
+ if (invalid.length > 0 && quarantined === null) {
293
+ const candidate = invalid[0];
294
+ const command = `tonedial doctor --fix --quarantine-operation ${candidate.operationId} ${connectionScopeFlag(ctx.agent)}`;
295
+ throw new ConfigError(t.recovery.quarantineRequired(candidate.operationId, command));
296
+ }
297
+ if (quarantined !== null && (operations.lock.state !== "none" || pending.length > 0)) {
228
298
  throw new ConfigError(t.doctor.recoveryPending);
229
299
  }
230
300
  const scopeExplicit = ctx.scopeExplicit ?? (ctx.flags.global || ctx.flags.here === true || ctx.flags.project !== null);
@@ -232,6 +302,14 @@ export async function runAppRebuild(ctx) {
232
302
  throw new ConfigError(t.errors.scopeRequired);
233
303
  if (ctx.flags.tools !== null)
234
304
  parseToolSelection(ctx.flags.tools, true, t);
305
+ if (quarantined !== null && invalid.length > 1) {
306
+ return quarantineOneOperation(ctx, quarantined, invalid.filter(entry => entry !== quarantined).map(entry => entry.operationId));
307
+ }
308
+ const blocked = operations.lock.state !== "none" || pending.length > 0;
309
+ if (blocked && (operations.lock.state === "live" || operations.lock.state === "unknown" ||
310
+ pending.some(entry => entry.journal === null) || (!ctx.flags.fromOperation && (ctx.flags.auto || !ctx.canPrompt)))) {
311
+ throw new ConfigError(t.doctor.recoveryPending);
312
+ }
235
313
  const configCapture = captureRawEntry(ctx.paths.config, t, { within: ctx.paths.root, maxBytes: CONFIG_MAX_BYTES });
236
314
  const selected = ctx.flags.fromOperation ? selectedJournal(ctx, ctx.flags.fromOperation) : null;
237
315
  let retirementId = null;
@@ -405,7 +483,11 @@ export async function runAppRebuild(ctx) {
405
483
  config.managedSeeds = seeds.records;
406
484
  config.seedVersion = SEED_VERSION;
407
485
  const artifact = compile(toCompileInput(config));
408
- const operationActions = [...seeds.operationActions];
486
+ let quarantinePlan = null;
487
+ if (quarantined !== null) {
488
+ quarantinePlan = planOperationQuarantine(ctx, quarantined);
489
+ }
490
+ const operationActions = [...(quarantinePlan ? [quarantinePlan] : []), ...seeds.operationActions];
409
491
  for (const target of artifacts) {
410
492
  const capture = target.capture ?? captureRawEntry(target.path, t, { within: ctx.paths.root, allowLink: true });
411
493
  const plan = planEntryRebuild(capture, artifact, t, { reason: "generated" });
@@ -457,6 +539,7 @@ export async function runAppRebuild(ctx) {
457
539
  ...(missingFields.length ? [` ${t.recovery.missing(missingFields)}`] : []),
458
540
  ...(copies.length ? [` ${t.recovery.copies(copies)}`] : []),
459
541
  ...(nativeAttention ? [` ${t.recovery.nativePending}`] : []),
542
+ ...(quarantined ? [` ${t.recovery.quarantine(quarantined.operationId)}`] : []),
460
543
  ...(retirementId ? [` ${t.recovery.retire(retirementId)}`] : []),
461
544
  ].flatMap(line => wrap(line.trimStart(), ctx.ui.width - 2).map(part => ` ${part}`));
462
545
  const options = {
@@ -484,6 +567,12 @@ export async function runAppRebuild(ctx) {
484
567
  if (capturedText(captureRawEntry(target.path, t, { within: ctx.paths.root })) !== artifact)
485
568
  throw mismatch();
486
569
  }
570
+ if (quarantined !== null) {
571
+ verificationPath = quarantined.path;
572
+ const entry = inspectOperations(ctx.paths).journals.find(({ operationId }) => operationId === quarantined.operationId);
573
+ if (entry?.state !== "quarantined")
574
+ throw mismatch();
575
+ }
487
576
  verificationPath = ctx.paths.tone;
488
577
  if (capturedText(captureRawEntry(ctx.paths.tone, t, { within: ctx.paths.root })) !== artifact)
489
578
  throw mismatch();
@@ -521,7 +610,7 @@ export async function runAppRebuild(ctx) {
521
610
  if (!ctx.flags.auto) {
522
611
  process.stdout.write(`${(preview.reason === "nothing" ? [...preview.lines, ...presentation] : preview.lines).join("\n")}\n`);
523
612
  if (await confirm(` ${t.wiring.applyQuestion(preview.diff.length)}`) === "no") {
524
- return { lines: [` ${t.wiring.declined}`], json: { appRebuilt: false, applied: false, reason: "declined", diff: preview.diff }, rebuiltConfig: null, proposedConfig: config, legacy };
613
+ return { lines: [` ${t.wiring.declined}`], json: { appRebuilt: false, applied: false, reason: "declined", diff: preview.diff }, rebuiltConfig: null, proposedConfig: config, legacy, quarantineOnly: false };
525
614
  }
526
615
  }
527
616
  const snapshots = [configCapture, ...seeds.reads, ...artifacts.flatMap(target => target.capture === null ? [] : [target.capture]),
@@ -551,12 +640,13 @@ export async function runAppRebuild(ctx) {
551
640
  lines.push(...presentation);
552
641
  lines.push(` ${applied ? result.reason === "nothing" ? t.recovery.unchanged : t.recovery.rebuilt : result.reason === "dry-run" ? t.wiring.dryRun : t.wiring.declined}`);
553
642
  return {
554
- lines, rebuiltConfig: applied ? config : null, proposedConfig: config, legacy,
643
+ lines, rebuiltConfig: applied ? config : null, proposedConfig: config, legacy, quarantineOnly: false,
555
644
  json: {
556
645
  appRebuilt: applied, applied: result.applied, reason: result.reason, operationId: result.operationId ?? null,
557
646
  root: ctx.paths.root, source, recovered: [...kept], defaulted: [...defaulted], invalid: damagedFields,
558
647
  seeded: seeds.labels, chars: [...artifact].length, riskAxes: riskyAxes(config.axes),
559
648
  recoveredPresets: copies, restoredPreset: restoredPreset?.slug ?? null, retiredOperation: retirementId,
649
+ quarantinedOperation: quarantined?.operationId ?? null,
560
650
  retiredExternalActions,
561
651
  nativeAttention, activationVerified: false, diff: result.diff,
562
652
  },
@@ -69,7 +69,8 @@ export function runShow(ctx, args) {
69
69
  const gauges = [gaugeHeader(ui, ctx.t), ...gaugeBlock(axes, ui)];
70
70
  const gaugeWidth = Math.max(...gauges.map((line) => maxCells(stripVTControlCharacters(line))));
71
71
  const detailWidth = ui.terminal && ui.width >= 119 ? ui.width - gaugeWidth - 4 : ui.width - 2;
72
- const details = [ui.bold(ctx.t.artifact.settingsHeading)];
72
+ const details = [ui.bold(ctx.t.artifact.settingsHeading),
73
+ ...(config.enabled ? [] : [ctx.t.delivery.status(false)])];
73
74
  if (personaSet) {
74
75
  const bits = [
75
76
  age ? humanText(age) : null,
@@ -163,6 +164,7 @@ export function runShow(ctx, args) {
163
164
  json: {
164
165
  preset: preview ? preview.slug : config.preset,
165
166
  preview: preview !== null,
167
+ enabled: config.enabled,
166
168
  activeKnown: preview !== null || !activeConflict,
167
169
  // Layered rather than flattened: the skill has to tell a forbidden contradiction from a
168
170
  // cosmetic one, and a joined string cannot carry that.
@@ -58,7 +58,8 @@ export async function runTest(ctx, args) {
58
58
  }
59
59
  input = toCompileInput(withPreset(config, preview));
60
60
  }
61
- const tone = compile(input);
61
+ // A test is an explicit one-shot preview, independent of the persistent native delivery switch.
62
+ const tone = compile({ ...input, enabled: true });
62
63
  const installed = PROVIDERS.map((provider) => ({ provider, program: onPath(provider, ctx.env) }))
63
64
  .filter((entry) => entry.program !== null);
64
65
  let selected = requested === undefined ? undefined : installed.find((entry) => entry.provider === requested);
@@ -0,0 +1,23 @@
1
+ // Pause or resume native tone delivery while preserving the saved settings and full artifact.
2
+ import { serializeConfig } from "../core/config.js";
3
+ import { wrap } from "../ui/width.js";
4
+ import { applyPlanned, planArtifact, planConfig } from "./compile.js";
5
+ async function setDelivery(ctx, enabled) {
6
+ const { change: configChange, config } = planConfig(ctx);
7
+ const changed = config.enabled !== enabled;
8
+ config.enabled = enabled;
9
+ const { change: toneChange, size } = planArtifact(ctx, config);
10
+ const finalConfig = { ...configChange, after: serializeConfig(config, ctx.t) };
11
+ await applyPlanned(ctx, [configChange, toneChange], [finalConfig]);
12
+ const message = enabled
13
+ ? changed ? ctx.t.delivery.on : ctx.t.delivery.alreadyOn
14
+ : changed ? ctx.t.delivery.off : ctx.t.delivery.alreadyOff;
15
+ return {
16
+ lines: [message, ...(enabled ? [] : [ctx.t.delivery.openConversation])]
17
+ .flatMap((line) => wrap(line, ctx.ui.width - 2))
18
+ .map((line) => ` ${ctx.ui.bold(changed ? ctx.ui.success(line) : line)}`),
19
+ json: { enabled, changed, path: ctx.paths.tone, chars: size.chars },
20
+ };
21
+ }
22
+ export const runOn = (ctx) => setDelivery(ctx, true);
23
+ export const runOff = (ctx) => setDelivery(ctx, false);
@@ -137,6 +137,8 @@ export const ANCHORS = {
137
137
  export const GENERATION_COMMENT = (preset) => `<!-- generated by tonedial · preset: ${preset ?? "unsaved"} -->`;
138
138
  /** Compact metadata read by native hook workers; keep the grammar deliberately strict. */
139
139
  export const STYLE_MARKER = (verbosity, care) => `<!-- tonedial-style:1 verbosity=${verbosity} care=${care} -->`;
140
+ /** The full compiled tone stays on disk while native delivery is paused. */
141
+ export const DELIVERY_OFF_MARKER = "<!-- tonedial-delivery:off -->";
140
142
  /** The artifact header keeps tone requests within host rules and permissions. */
141
143
  export const SCOPE_HEADER = `## Response tone
142
144
  Output style only. This never overrides the project's code or task rules, and never changes